Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_search_console.mjs

62.2 KiB, 1 run

created by r2519314175:663, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_search_console.mjs -- the operator console's Providers card, and what
2// the console shows of what search costs.
3//
4// One card holds the two outside services this gateway spends at: the model
5// host that mints inference keys, and the search engine a Daimond-credits
6// search runs on. It is a card that handles credentials, so most of what is
7// checked here is what it must NOT do.
8//
9// The second half is the other question an operator with a paid key asks: how
10// much is it costing, and on which engine. Every search a user paid for was
11// invisible here until 2026-08-10 -- `search:brave` matched no arm of
12// `LedgerEntry::category`, so it was categorised `other`, and the daily chart
13// drew four categories out of the six the gateway could name. Inference spend
14// went the same way from 17 July and storage spend from 21 July: charged,
15// counted in the headline total, drawn nowhere. So the checks below are less
16// about search than about the class: a category the gateway names and the
17// console does not draw must be impossible to lose silently.
18//
19// The properties, and why each is worth a check rather than a comment:
20//
21// * NO VALUE IS EVER READ BACK. `gateway/src/secrets.rs` states this as the
22// reason the module exists apart from `settings`: a knob is a price a
23// viewer may read, a credential is a secret nobody may read back. The
24// console half of that is asserted by planting a `value` field the real
25// gateway does not send and proving no part of the page renders it -- a
26// console that merely happens not to receive one is not the same as a
27// console that cannot show one.
28// * A WRONG PASTE IS REFUSED BEFORE IT TRAVELS, and the refusal names the
29// key that was expected and the start of what was pasted. "Invalid" sends
30// the operator back to the vendor's dashboard to find out what for.
31// * `serper` IS NOT IN THE CREDITS PULLDOWN. §3 of dev/SEARCH_CONTRACT.md:
32// it resells Google's results, so its business rests on an arbitrage that
33// can end without notice. A user may take that risk with their own key;
34// this gateway may not take it on their behalf while billing for it.
35// * SETTING A KEY NEEDS NO RESTART. That is the whole point of the secrets
36// module, and a card whose help text implies otherwise has undone it.
37// * EVERY CATEGORY THE GATEWAY NAMES IS DRAWN, and anything it counted that
38// the page cannot name is drawn as a remainder rather than subtracted. The
39// day's `total` is what that is measured against, which is why the gateway
40// sends one.
41// * SEARCH SPEND IS BROKEN DOWN BY ENGINE. A cap set at a vendor is set on
42// one engine, in queries, so a category total cannot answer the question
43// the cap raises.
44// * A FIGURE THAT DID NOT ARRIVE IS NOT DRAWN AS ZERO, and a genuine zero
45// still reads as zero. `Store::scan_prefix` walks the whole key space, and
46// on a 952 MB store with 3,467 accounts the ten views this console fires at
47// once queue on one bot per zone and three of them time out every round.
48// The gateway answers that with `503 {ok:false, error, unread, …every
49// figure that WAS read}` and deliberately OMITS what it could not read
50// rather than zeroing it -- see `Unread` in gateway/src/handlers/admin.rs.
51// That is only worth doing if the console draws the gap as a gap: "0 B
52// stored" over a store that never answered is wrong and looks right, and an
53// operator acts on it. So the two states are asserted apart, in both
54// directions, on the same card.
55// * A PARTIAL ANSWER IS DRAWN, NOT DISCARDED, and the reason reaches the
56// screen. The rule `refreshAll` argues for panels applies one level down to
57// figures: a card showing five of its six readings and naming the sixth
58// beats one showing none. And the reply names WHICH figure and WHY, so a
59// console that reports "something failed" has thrown away what it was sent.
60//
61// Two legs, because neither alone reaches both halves. The first drives the
62// page against a stubbed `/api/admin`, which is the only way to plant a
63// hostile response and see what the page does with it. The second drives the
64// REAL gateway with a real owner session, because "the registry answers with
65// no value" is a claim about the gateway, and a stub of my own writing cannot
66// testify to it.
67//
68// node dev/verify_search_console.mjs
69// node dev/verify_search_console.mjs --break value # the row renders s.value
70// node dev/verify_search_console.mjs --break prefix # the paste check is skipped
71// node dev/verify_search_console.mjs --break serper # serper back in the pulldown
72// node dev/verify_search_console.mjs --break restart # "restart the gateway"
73// node dev/verify_search_console.mjs --break viewer # a viewer fetches the keys
74// node dev/verify_search_console.mjs --break twice # both cards draw the engine
75// node dev/verify_search_console.mjs --break searchcat # search folded into Other
76// node dev/verify_search_console.mjs --break engines # no per-engine breakdown
77// node dev/verify_search_console.mjs --break rest # a lost category is subtracted
78// node dev/verify_search_console.mjs --break zero # an absent figure formatted as 0
79// node dev/verify_search_console.mjs --break swallow # adminFetch drops the partial body
80// node dev/verify_search_console.mjs --break silent # the reason never leaves part()
81// node dev/verify_search_console.mjs --break nocap # an unread ceiling reads "no cap is set"
82//
83// Each --break is a defect one of the checks below is supposed to catch. A
84// break that runs green means the check for it is worthless.
85//
86// Needs a dev server for the page, which it starts itself if one is not
87// already answering, and for the second leg a free :9002 and a gateway built
88// from current source -- see dev/gwbin.mjs, which refuses to measure a stale
89// one rather than reporting numbers about a build nobody is shipping.
90//
91// The second leg's checks on `brave_key` and its three companions fail until
92// lane `gateway` registers them. That is reported rather than skipped: an
93// operator whose Search group has nothing in it is looking at the same absence.
94
95import os from 'node:os';
96import path from 'node:path';
97import { spawn } from 'node:child_process';
98import { fileURLToPath, pathToFileURL } from 'node:url';
99import { requireFreshGateway, GWCWD } from './gwbin.mjs';
100import { signInFresh } from './session.mjs';
101import { GW_PORT, GW_URL } from './ports.mjs';
102
103// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
104// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
105// run under `xvfb-run` still went to the compositor and opened a window on the owner's
106// desktop. Importing this strips the two variables from `process.env`, which is all a
107// launcher that spreads `process.env` needs. See dev/display.mjs.
108import './display.mjs';
109const HERE = path.dirname(fileURLToPath(import.meta.url));
110const ROOT = path.join(HERE, '..');
111const GWDIR = path.join(ROOT, 'gateway');
112const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`;
113
114const PW = process.env.DAIMOND_PW
115 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
116const CHROME = process.env.DAIMOND_CHROME
117 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
118
119const BREAK = (() => {
120 const i = process.argv.indexOf('--break');
121 return i >= 0 ? (process.argv[i + 1] || 'value') : null;
122})();
123
124const ok = [], bad = [];
125const check = (name, pass, detail) => {
126 (pass ? ok : bad).push(name);
127 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' -- ' + detail : ''));
128};
129const sleep = ms => new Promise(r => setTimeout(r, ms));
130
131// Ahead of anything spawned, and ahead of the stub leg too: a stale binary
132// makes the second leg measure a gateway three days older than the code, and
133// there is no point running half a suite that is going to refuse anyway.
134requireFreshGateway();
135
136// ── What the console is told ────────────────────────────────
137//
138// A value the gateway never sends, planted in every row. Any part of the page
139// that shows it has found a way to disclose a credential, and the point of the
140// plant is that it costs the console nothing to be blind to it.
141// allowlist secret -- a fixture, and the whole point is that it is never real.
142const PLANTED = 'sk-or-v1-PLANTEDSECRET000000000000';
143
144/// The registry as the console sees it. The prefixes here are the stub's own:
145/// nothing below asserts what a real one IS -- only that the console honours
146/// whichever it was handed. The live leg reads the real ones.
147///
148/// The SHAPE is not the stub's own, and must not drift: three search
149/// credentials and deliberately no `serper_key`, because credits cannot buy a
150/// Serper search (§3 of dev/SEARCH_CONTRACT.md) and a key nothing could spend
151/// is a key nobody should be asked for. A stub that grew a fourth would be
152/// proving the console right against a registry the gateway does not have.
153function stubSecrets(overrides) {
154 const rows = [
155 { route: '/api/inference-key', key: 'openrouter_key',
156 label: 'OpenRouter management key', prefix: 'sk-or-v1-',
157 help: 'Mints the spend-capped keys that account credits buy inference with.',
158 set: true, hint: '…cdef', overridden: true, configured: false,
159 set_by: 'acct_owner', set_at: 1754800000 },
160 { route: '/api/web/search', key: 'brave_key', label: 'Brave Search key',
161 prefix: 'BSA', help: 'Its own crawler.', set: false, hint: '',
162 overridden: false, configured: false, set_by: '', set_at: 0 },
163 { route: '/api/web/search', key: 'exa_key', label: 'Exa key',
164 prefix: '', help: 'Neural retrieval.', set: false, hint: '',
165 overridden: false, configured: false, set_by: '', set_at: 0 },
166 { route: '/api/web/search', key: 'tavily_key', label: 'Tavily key',
167 prefix: 'tvly-', help: '', set: false, hint: '',
168 overridden: false, configured: false, set_by: '', set_at: 0 },
169 ];
170 return rows.map(r => Object.assign({ value: PLANTED }, r, (overrides || {})[r.key] || {}));
171}
172
173/// The knob catalogue, with the engine among ordinary priced knobs so that
174/// "the Settings card stopped drawing it" is a statement about one knob and
175/// not about an empty response.
176///
177/// Its companion is `search_byok_minor`, which is a knob the real registry
178/// actually has. It used to be `search_min_charge_minor`, a floor that was
179/// deleted when searches started accumulating in millionths; a fabricated
180/// response cannot fail against a gateway, so nothing caught that it had come
181/// to describe a knob nobody could set. The label is the registry's own, and it
182/// belongs to no other group, so the check that reads for it is a statement
183/// about THIS group surviving rather than about the page having any knobs left.
184function stubSettings(engine) {
185 return {
186 ok: true, currency: 'usd',
187 groups: [
188 { route: '/api/web/search', title: 'Web search',
189 help: 'Searching through the gateway.',
190 knobs: [
191 { key: 'search_engine', label: 'Engine for credit searches',
192 help: 'Which engine a search bought with Daimond credits runs on.',
193 kind: 'text', unit: '', value: engine, default: 'brave',
194 overridden: engine !== 'brave', set_by: 'acct_owner', set_at: 1754800000 },
195 { key: 'search_byok_minor', label: 'Charge to relay a search',
196 help: 'What it costs to relay a search the user\'s own key pays for.',
197 kind: 'money', unit: 'minor units', value: '1', default: '1',
198 overridden: false, set_by: '', set_at: 0 },
199 ] },
200 { route: '/api/web/fetch', title: 'Web fetch', help: 'Reading a page.',
201 knobs: [
202 { key: 'min_charge_minor', label: 'Minimum charge', help: '',
203 kind: 'money', unit: 'minor units', value: '1', default: '1',
204 overridden: false, set_by: '', set_at: 0 },
205 ] },
206 ],
207 };
208}
209
210/// Every category the gateway names a spend by -- SPEND_CATEGORIES in
211/// gateway/src/schema.rs. Written out here rather than derived, because the two
212/// halves drifting apart IS the defect these checks are about, and a fixture
213/// that reads its expectations off the thing under test proves nothing.
214const CONSUME_KEYS = ['web', 'search', 'mail', 'sync', 'storage', 'infer', 'other'];
215
216/// The consumption view as the gateway answers it: one field per category on
217/// every day, spent or not, the day's own `total`, and the window's search
218/// spend split by the engine that answered.
219///
220/// `o.lastTotal` overrides the last day's total so it exceeds its named parts,
221/// which is what a gateway that has learned a category this page has not looks
222/// like. `o.empty` is a gateway with nothing to report.
223function stubConsumption(o) {
224 o = o || {};
225 const day = (ts, vals, totalOver) => {
226 const d = { ts };
227 let sum = 0;
228 for (const k of CONSUME_KEYS) { d[k] = (vals || {})[k] || 0; sum += d[k]; }
229 d.total = totalOver == null ? sum : totalOver;
230 return d;
231 };
232 if (o.empty) return { ok: true, days: 30, points: [], engines: [] };
233 const t0 = Date.UTC(2026, 7, 8), DAY = 86400000;
234 return {
235 ok: true, days: 30,
236 points: [
237 day(t0, { web: 400, mail: 120 }),
238 day(t0 + DAY, { web: 250, search: 300, storage: 90, infer: 700 }),
239 day(t0 + 2 * DAY, { search: 180, sync: 40, other: 20 }, o.lastTotal),
240 ],
241 // 400 + 80 = 480 = the search category over the same window, because a
242 // breakdown is a partition of it and not a second helping.
243 engines: o.engines || [
244 { engine: 'brave', total: 400, searches: 40 },
245 { engine: 'exa', total: 80, searches: 4 },
246 ],
247 };
248}
249
250// ── A store that stopped answering ──────────────────────────
251//
252// What the gateway actually said, quoted from its log on 2026-08-10: a prefix
253// scan waiting on the two zone bots. Written out rather than invented, because
254// what the console does with the reason is one of the things asserted, and a
255// made-up sentence proves nothing about the one an operator will see.
256const TIMED_OUT = 'Expecting 2 messages via responder, received 0 when timed out after 6s.';
257/// Once a read has failed the rest are named but NOT attempted -- they queue on
258/// the bot that has just timed out. `Unread::take` gives the reasoning.
259const NOT_ASKED = 'not asked for: the store had already stopped answering';
260
261/// The `error` sentence the gateway builds from its own gaps, so the fixture
262/// cannot say one thing in `unread` and another in `error`.
263const unreadSentence = u => u.map(x => `${x.what} could not be read: ${x.why}`).join(' ');
264
265/// `capacity` when every read lands.
266///
267/// A complete answer is half of what these checks need: an absent figure only
268/// means something beside a present one. `{ empty: true }` is the other half --
269/// a gateway holding nothing at all, whose GENUINE ZEROES must keep reading as
270/// zeroes and must not be mistaken for figures the store would not give.
271function stubCapacity(o) {
272 o = o || {};
273 const z = !!o.empty;
274 return {
275 ok: true,
276 storage: {
277 bytes: z ? 0 : 3221225472, // 3 GiB
278 free_tier_bytes: z ? 0 : 2147483648,
279 paid_bytes: z ? 0 : 1073741824,
280 accounts: z ? 0 : 12,
281 cap_bytes: z ? 0 : 10737418240, // 10 GiB, or none set
282 host_disk_bytes: z ? 0 : 107374182400,
283 },
284 egress: {
285 month: '2026-08',
286 bytes: z ? 0 : 536870912,
287 plan_bytes: z ? 0 : 21474836480,
288 alert_bytes: z ? 0 : 17179869184,
289 },
290 overage: { per_gb_minor: z ? 0 : 12, currency: 'aud' },
291 };
292}
293
294/// `capacity` when the store stopped answering part way through it.
295///
296/// The order is the gateway's own (`capacity` in gateway/src/handlers/
297/// admin.rs): what the store holds lands, the month's egress times out, and the
298/// six knobs behind it are named but not attempted. So `storage.bytes` is a
299/// real reading while `egress.bytes` is absent -- and `cap_bytes`, which comes
300/// from the knobs, is absent though the figure it sits beside is not.
301///
302/// That last pairing is the one worth planting. A console that fills a missing
303/// ceiling with `0` reports "no cap is set" over a gateway that is refusing
304/// uploads at one, which is the same wrong sentence the gateway stopped
305/// answering with, arriving through the door nobody was watching.
306function stubCapacityPartial() {
307 const unread = [
308 { what: "This month's metered egress", why: TIMED_OUT },
309 { what: 'The ceiling, the plan and the overage rate', why: NOT_ASKED },
310 ];
311 return {
312 ok: false, error: unreadSentence(unread), unread,
313 storage: { bytes: 3221225472, free_tier_bytes: 2147483648,
314 paid_bytes: 1073741824, accounts: 12 },
315 egress: { month: '2026-08' },
316 overage: {},
317 };
318}
319
320/// `summary` when the account list landed and the ledger walk did not.
321///
322/// The account totals are real readings and the four money-and-count tiles
323/// beside them are gone, which is the shape an operator meets: a dashboard that
324/// can still tell him how many accounts there are while it cannot tell him what
325/// they owe.
326function stubSummaryPartial() {
327 const unread = [
328 { what: 'Credits outstanding, revenue and consumption',
329 why: `41 of 3467 account ledgers were read before the store stopped answering: ${TIMED_OUT}` },
330 { what: 'The Pro licence count', why: NOT_ASKED },
331 { what: 'The entitlement count', why: NOT_ASKED },
332 { what: 'Sync storage', why: NOT_ASKED },
333 { what: 'The mailbox count', why: NOT_ASKED },
334 ];
335 return {
336 ok: false, error: unreadSentence(unread), unread,
337 accounts: 3467, new_24h: 12, new_7d: 61, new_30d: 240,
338 health: { store_ok: true, api: 1 },
339 };
340}
341
342/// A view that failed OUTRIGHT, naming itself. Every view now does: `app_main`
343/// used to answer eleven of them with one `api handler error` that named none.
344const GEO_FAILED = `The 'geo' view failed: ${TIMED_OUT}`;
345
346/// `views` replaces whole answers, which is how a hostile one is planted.
347///
348/// `summary` is written out complete, zero by zero, rather than left short:
349/// `ok: true` is the gateway promising every figure was read, and a stub that
350/// omits half of them while claiming it would have the console marking gaps
351/// that the thing under test never had.
352function stubFor(role, engine, secretOverrides, consume, views) {
353 return Object.assign({
354 whoami: { ok: true, account_id: 'acct_test', client_fp: '0000 0000 0000 0000',
355 role, can_grant: role === 'owner' },
356 summary: { ok: true, health: { store_ok: true, api: 1 },
357 accounts: 0, new_24h: 0, new_7d: 0, new_30d: 0,
358 credits_minor: 0, revenue: [], consumption: [],
359 pro_licences: 0, entitlements: 0,
360 sync_parcels: 0, sync_bytes: 0, mailboxes: 0 },
361 revenue: { ok: true, days: [] },
362 consumption: stubConsumption(consume),
363 geo: { ok: true, rows: [] },
364 accounts: { ok: true, rows: [], page: 0, pages: 1, total: 0 },
365 ledger: { ok: true, rows: [], page: 0, pages: 1, total: 0 },
366 releases: { ok: true, declared: [], planned: null, builds: [] },
367 operators: { ok: true, rows: [] },
368 capacity: stubCapacity(),
369 settings: stubSettings(engine || 'brave'),
370 secrets: { ok: true, secrets: stubSecrets(secretOverrides) },
371 }, views || {});
372}
373
374// ── Driving the page ────────────────────────────────────────
375
376/// Every admin call the page made, read AND write.
377///
378/// Both directions are recorded because both are asserted: "the key never left
379/// the browser" is about what was posted, and "a viewer never asks for the
380/// keys" is about what was fetched. A refusal that still posts has refused
381/// nothing, and a viewer whose console asks anyway is relying on the gateway to
382/// say no.
383function openStub(browser, opts) {
384 const o = opts || {};
385 const calls = [];
386 return (async () => {
387 const page = await browser.newPage({ viewport: { width: 1400, height: 1100 } });
388 const stub = stubFor(o.role || 'owner', o.engine, o.secrets, o.consume, o.views);
389 await page.route('**/api/admin*', async route => {
390 const req = route.request();
391 const view = new URL(req.url()).searchParams.get('view');
392 if (req.method() === 'POST') {
393 let body = null;
394 try { body = JSON.parse(req.postData() || 'null'); } catch (e) {}
395 calls.push({ method: 'POST', view, body });
396 const answer = o.onPost ? o.onPost(view, body, stub) : { ok: true };
397 await route.fulfill({ status: answer.status || 200,
398 contentType: 'application/json', body: JSON.stringify(answer.json || answer) });
399 return;
400 }
401 calls.push({ method: 'GET', view, body: null });
402 // The status is taken FROM the body, so the stub cannot drift from
403 // the gateway's own rule: `200 {ok:true, …}` where every figure was
404 // read, `503 {ok:false, error, unread, …the ones that were}` where
405 // they were not. 503 and not 500 -- a scan that timed out because
406 // the store was busy is a condition that passes.
407 const answer = stub[view] || { ok: true };
408 await route.fulfill({ status: answer.ok === false ? 503 : 200,
409 contentType: 'application/json', body: JSON.stringify(answer) });
410 });
411 if (BREAK) await page.addInitScript(mode => { window.__provBreak = mode; }, BREAK);
412 await page.goto(APP + '/console/', { waitUntil: 'domcontentloaded' });
413 await page.waitForSelector('#admin-app:not([hidden])', { timeout: 15000 });
414 // The card is filled by the settings and secrets fetches, which land
415 // after the dashboard un-hides. Wait for the card to have CONTENT, not
416 // for a guessed interval, or the first read counts an empty card.
417 await page.waitForFunction(
418 () => (document.getElementById('admin-prov-groups') || {}).childElementCount > 0,
419 { timeout: 15000 }).catch(() => {});
420 return { page, calls };
421 })();
422}
423
424/// The writes among them, which is what "nothing was sent" is about.
425function sent(calls) { return calls.filter(c => c.method === 'POST'); }
426
427/// Show the Settings section and prove it is really showing.
428///
429/// Every view but Overview starts `hidden`, and a check that only READS the
430/// DOM passes against a hidden element -- it is the first line that has to TYPE
431/// that fails, with "element is not visible", somewhere far from the cause. So
432/// the rail item is clicked and visibility is asserted before anything is
433/// touched. `offsetParent` is the honest test: it is null for anything inside a
434/// hidden ancestor, which a `hidden` attribute on the panel is.
435async function showProviders(page, label) {
436 await page.click('#admin-nav .admin-nav-item[data-view="settings"]', { force: true });
437 await sleep(150);
438 const vis = await page.evaluate(() => {
439 const card = document.getElementById('admin-prov-card');
440 if (!card) return { ok: false, why: 'no Providers card in the page' };
441 const r = card.getBoundingClientRect();
442 return { ok: card.offsetParent !== null && r.width > 8 && r.height > 8,
443 why: `offsetParent ${card.offsetParent ? 'set' : 'null'}, box ${Math.round(r.width)}x${Math.round(r.height)}` };
444 });
445 check(`${label}: the Providers card is visible once Settings is chosen`, vis.ok, vis.why);
446 return vis.ok;
447}
448
449/// What the card is showing, as things worth asserting about.
450function readCard(page) {
451 return page.evaluate(planted => {
452 const card = document.getElementById('admin-prov-card');
453 const set = document.getElementById('admin-set-card');
454 const groups = Array.from(card.querySelectorAll('.admin-set-group')).map(g => ({
455 head: (g.querySelector('h3') || {}).textContent || '',
456 route: (g.querySelector('.admin-set-route') || {}).textContent || '',
457 keys: Array.from(g.querySelectorAll('.admin-prov-key')).map(r => r.dataset.key),
458 }));
459 const sel = document.getElementById('admin-prov-engine');
460 const opts = sel ? Array.from(sel.options).map(o => ({
461 value: o.value, text: o.textContent, disabled: o.disabled })) : null;
462 // Everything a value could hide in: rendered text, every input's live
463 // value, and every value attribute in the markup.
464 const inputs = Array.from(card.querySelectorAll('input')).map(i => ({
465 type: i.type, value: i.value, attr: i.getAttribute('value') }));
466 return {
467 text: card.textContent || '',
468 setText: set ? (set.textContent || '') : '',
469 html: card.innerHTML,
470 hint: (document.getElementById('admin-prov-hint') || {}).textContent || '',
471 note: (document.getElementById('admin-prov-note') || {}).textContent || '',
472 noteShown: !!(document.getElementById('admin-prov-note')
473 && !document.getElementById('admin-prov-note').hidden),
474 groups, opts, inputs,
475 planted: (card.innerHTML || '').includes(planted),
476 // Where the two cards sit relative to each other, as the document
477 // order a reader meets them in.
478 provFirst: !!(card && set
479 && (card.compareDocumentPosition(set) & Node.DOCUMENT_POSITION_FOLLOWING)),
480 engineValue: sel ? sel.value : null,
481 said: Array.from(card.querySelectorAll('.admin-prov-said')).map(n => n.textContent),
482 rowMsgs: Array.from(card.querySelectorAll('.admin-set-msg'))
483 .map(n => n.textContent).filter(Boolean),
484 };
485 }, PLANTED);
486}
487
488/// Show the Overview section and prove it is really showing.
489///
490/// Overview is the section the console opens on, so this looks redundant --
491/// until a rail click earlier in the run has left another panel up, and every
492/// read below quietly answers about a `hidden` ancestor. Same rule as
493/// showProviders: click the rail item, then assert the chart has a box.
494async function showOverview(page, label) {
495 await page.click('#admin-nav .admin-nav-item[data-view="overview"]', { force: true });
496 await sleep(150);
497 const vis = await page.evaluate(() => {
498 const host = document.getElementById('admin-consumption');
499 if (!host) return { ok: false, why: 'no consumption chart in the page' };
500 const r = host.getBoundingClientRect();
501 return { ok: host.offsetParent !== null && r.width > 8 && r.height > 8,
502 why: `offsetParent ${host.offsetParent ? 'set' : 'null'}, box ${Math.round(r.width)}x${Math.round(r.height)}` };
503 });
504 check(`${label}: the consumption chart is visible on Overview`, vis.ok, vis.why);
505 return vis.ok;
506}
507
508/// What the consumption card is showing, as things worth asserting about.
509function readConsumption(page) {
510 return page.evaluate(() => {
511 const host = document.getElementById('admin-consumption');
512 const lg = document.getElementById('admin-consumption-legend');
513 const eng = document.getElementById('admin-search-engines');
514 const card = host ? host.closest('.admin-card') : null;
515 return {
516 // Which categories the stack actually drew, as the classes the CSS
517 // colours them by.
518 segs: Array.from(host ? host.querySelectorAll('rect.admin-bar') : [])
519 .map(r => (r.getAttribute('class') || '').replace('admin-bar', '').trim())
520 .filter(Boolean),
521 // The hover text, which is where a reader learns what a segment is.
522 titles: Array.from(host ? host.querySelectorAll('title') : [])
523 .map(t => t.textContent),
524 empty: !!(host && host.querySelector('.admin-chart-empty')),
525 legend: lg ? (lg.textContent || '') : '',
526 swatches: Array.from(lg ? lg.querySelectorAll('.sw') : [])
527 .map(s => (s.className || '').replace('sw', '').trim()),
528 engPresent: !!eng,
529 engText: eng ? (eng.textContent || '') : '',
530 engRows: Array.from(eng ? eng.querySelectorAll('tbody tr') : []).map(tr => ({
531 engine: tr.getAttribute('data-engine'),
532 cells: Array.from(tr.querySelectorAll('td')).map(td => td.textContent),
533 })),
534 // The breakdown has to be beside the chart it breaks down: a number
535 // in another panel is a number an operator has to go and find.
536 inSameCard: !!(eng && card && card.contains(eng)),
537 };
538 });
539}
540
541/// Show the Capacity section and prove it is really showing.
542///
543/// Same rule as showProviders: the panel starts `hidden`, and a check that only
544/// READS the DOM passes against a hidden element. The card is also filled last
545/// of everything `refreshAll` does -- after the accounts and ledger tables --
546/// so the wait is for the card to have a BLOCK in it rather than for an
547/// interval somebody guessed.
548async function showCapacity(page, label) {
549 await page.click('#admin-nav .admin-nav-item[data-view="capacity"]', { force: true });
550 await page.waitForFunction(
551 () => document.querySelectorAll('#admin-cap-card .admin-cap').length >= 2,
552 { timeout: 15000 }).catch(() => {});
553 const vis = await page.evaluate(() => {
554 const card = document.getElementById('admin-cap-card');
555 if (!card) return { ok: false, why: 'no Capacity card in the page' };
556 const r = card.getBoundingClientRect();
557 return { ok: card.offsetParent !== null && r.width > 8 && r.height > 8,
558 why: `offsetParent ${card.offsetParent ? 'set' : 'null'}, box ${Math.round(r.width)}x${Math.round(r.height)}` };
559 });
560 check(`${label}: the Capacity card is visible once Capacity is chosen`, vis.ok, vis.why);
561 return vis.ok;
562}
563
564/// The KPI tiles and the strip above them, as things worth asserting about.
565///
566/// The strip is read only when it is SHOWING: `#admin-status` keeps its last
567/// text after being hidden, and a check that read it regardless would pass on
568/// a message nobody can see.
569function readKpis(page) {
570 return page.evaluate(() => {
571 const strip = document.getElementById('admin-status');
572 return {
573 tiles: Array.from(document.querySelectorAll('#admin-kpis .admin-kpi')).map(t => ({
574 label: (t.querySelector('.admin-kpi-lbl') || {}).textContent || '',
575 val: (t.querySelector('.admin-kpi-val') || {}).textContent || '',
576 sub: (t.querySelector('.admin-kpi-sub') || {}).textContent || '',
577 marked: t.classList.contains('absent'),
578 })),
579 strip: strip && !strip.hidden ? (strip.textContent || '') : '',
580 };
581 });
582}
583/// One tile by the label a reader sees.
584const tile = (k, label) =>
585 k.tiles.find(t => t.label === label) || { label, val: '', sub: '', marked: null };
586
587/// The capacity card, one object per block.
588///
589/// `rail` is the three states the bar can be in and they are not
590/// interchangeable: a proportion, a limit nobody set, and a reading the store
591/// would not give. The last two are both empty bars, which is exactly why the
592/// class is read rather than the pixels.
593function readCapacity(page) {
594 return page.evaluate(() => {
595 const block = id => {
596 const host = document.getElementById(id);
597 const b = host ? host.querySelector('.admin-cap') : null;
598 const m = b ? b.querySelector('.admin-meter') : null;
599 const txt = (sel) => (b && b.querySelector(sel) || {}).textContent || '';
600 return {
601 head: txt('.admin-cap-headline'),
602 legend: txt('.admin-cap-legend'),
603 detail: txt('.admin-cap-detail'),
604 words: txt('.admin-cap-words'),
605 marked: !!(b && b.classList.contains('absent')),
606 rail: !m ? 'none'
607 : m.classList.contains('unread') ? 'unread'
608 : m.classList.contains('nolimit') ? 'nolimit' : 'proportion',
609 };
610 };
611 const err = document.getElementById('admin-cap-err');
612 return {
613 storage: block('admin-cap-storage'),
614 egress: block('admin-cap-egress'),
615 hint: (document.getElementById('admin-cap-hint') || {}).textContent || '',
616 err: err ? (err.textContent || '') : '',
617 // A view that answered in part is not a failure and must not be
618 // dressed as one: the card's line takes the status strip's register
619 // rather than the red kept for a view that failed outright.
620 errCalm: !!(err && err.classList.contains('admin-partial')),
621 };
622 });
623}
624
625/// Type into one credential's field and press its Save.
626async function pasteKey(page, key, value) {
627 const row = `.admin-prov-key[data-key="${key}"]`;
628 await page.fill(`${row} input.admin-set-input`, value);
629 await page.click(`${row} button.admin-btn`, { force: true });
630 await sleep(300);
631}
632
633// ── The processes the second leg needs ──────────────────────
634const procs = [];
635function launch(cmd, args, opts) { const p = spawn(cmd, args, opts); procs.push(p); return p; }
636async function waitFor(fn, ms = 20000, gap = 300) {
637 const t0 = Date.now();
638 for (;;) {
639 try { if (await fn()) return true; } catch (e) {}
640 if (Date.now() - t0 > ms) return false;
641 await sleep(gap);
642 }
643}
644function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } }
645
646let gw = null;
647async function startGateway(ownerAccount) {
648 if (gw) { try { gw.kill('SIGKILL'); } catch (e) {} await sleep(1500); }
649 gw = launch(path.join(GWDIR, 'target/release/daimond_gateway'), [], {
650 cwd: GWCWD,
651 env: { ...process.env, APP_MODE: 'sandbox',
652 ...(ownerAccount ? { DAIMOND_OWNER_ACCOUNTS: ownerAccount } : {}) },
653 stdio: ['ignore', 'ignore', 'ignore'],
654 });
655 return await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok);
656}
657
658/// An admin call from a page, carrying that page's own session.
659async function api(page, view, body) {
660 return await page.evaluate(async a => {
661 const opts = { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } };
662 if (a.body !== null) {
663 opts.method = 'POST';
664 opts.headers['content-type'] = 'application/json';
665 opts.body = JSON.stringify(a.body);
666 }
667 const r = await fetch('/api/admin?view=' + a.view, opts);
668 let j = null; try { j = await r.json(); } catch (e) {}
669 return { status: r.status, j };
670 }, { view, body: body === undefined ? null : body });
671}
672
673// ── Run ─────────────────────────────────────────────────────
674
675// The dev server serves /console/, so BOTH legs need it -- the stub leg
676// intercepts the API and still has to load the page from somewhere. Started
677// here rather than inside the second leg, where it was serving the first one by
678// accident of whatever was already running.
679{
680 let already = false;
681 try { already = (await fetch(`${APP}/console/`)).ok; } catch (e) {}
682 if (!already) launch('node', ['dev/serve.mjs'], { cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'] });
683 const up = await waitFor(async () => (await fetch(`${APP}/console/`)).ok, 15000);
684 check('the dev server serves the console', up, APP + '/console/');
685 if (!up) {
686 cleanup();
687 console.log(`\npassed ${ok.length}, failed ${bad.length}`);
688 process.exit(1);
689 }
690}
691
692const { chromium } = await import(pathToFileURL(PW).href);
693// Launched as dev/harness.mjs launches: no mode on this host produces animation
694// frames, so Playwright's stability check never settles and every click is
695// forced. Where that matters -- a control that might be covered -- the reach is
696// asserted separately, as showProviders does.
697const browser = await chromium.launch({ executablePath: CHROME, headless: false,
698 args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'] });
699
700try {
701 // ── Leg one: the page, against a stub ───────────────────
702 {
703 const { page, calls } = await openStub(browser, { role: 'owner' });
704 const shown = await showProviders(page, 'owner');
705
706 if (shown) {
707 const c = await readCard(page);
708
709 // Where it sits, and what it holds.
710 check('Providers comes before Settings in the section', c.provFirst,
711 'the two cards are the other way round, or one is missing');
712 const heads = c.groups.map(g => g.head);
713 check('the card groups Inference and Search, in that order',
714 heads.indexOf('Inference') === 0 && heads.indexOf('Search') === 1,
715 'groups: ' + JSON.stringify(heads));
716 const searchGroup = c.groups.find(g => g.head === 'Search') || { keys: [] };
717 const infGroup = c.groups.find(g => g.head === 'Inference') || { keys: [] };
718 check('the inference key is in the Inference group',
719 infGroup.keys.includes('openrouter_key'), JSON.stringify(infGroup.keys));
720 // THREE, not four. An operator serper key was registered by the
721 // contract and removed on purpose: the credits tier may never spend
722 // serper, and a BYOK key is never stored -- so the row would be a live
723 // third-party credential at rest that no code path can reach. Given
724 // what the write path was nearly doing with credentials nobody had
725 // thought hard about, a dead one is the wrong thing to leave lying
726 // about. Asserted as an absence below, so it cannot creep back.
727 for (const k of ['brave_key', 'exa_key', 'tavily_key']) {
728 check(`${k} has a row in the Search group`, searchGroup.keys.includes(k),
729 'Search holds ' + JSON.stringify(searchGroup.keys));
730 }
731 // And the absence, asserted rather than merely not asserted: a key
732 // nothing can spend is a credential kept for no reason, and the day
733 // somebody wires it up they break the rule that the credits tier
734 // never buys resold results.
735 check('no operator serper key is registered, because nothing could spend it',
736 !searchGroup.keys.includes('serper_key'),
737 'Search holds ' + JSON.stringify(searchGroup.keys));
738
739 // No value, by any route.
740 check('the planted value appears nowhere in the card', !c.planted,
741 'a credential the gateway never sent was rendered');
742 check('no field in the card holds a value',
743 c.inputs.every(i => i.value === '' && !i.attr),
744 JSON.stringify(c.inputs.filter(i => i.value || i.attr)));
745 check('every credential field is a password field',
746 c.inputs.length > 0 && c.inputs.every(i => i.type === 'password'),
747 JSON.stringify(c.inputs.map(i => i.type)));
748 check('presence is shown as the masked tail the gateway sent',
749 /…cdef/.test(c.text) && !/sk-or-v1-\w/.test(c.text),
750 'the value column did not read as a hint');
751 check('a key that is not set says so rather than showing nothing',
752 /not set/.test(c.text));
753 check('provenance is named for the key that is set',
754 /set by acct_owner/.test(c.text), 'no provenance line found');
755
756 // The engine pulldown.
757 check('there is an engine pulldown', !!c.opts, 'no #admin-prov-engine');
758 if (c.opts) {
759 const choosable = c.opts.filter(o => !o.disabled).map(o => o.value);
760 check('serper is not an option at all',
761 !c.opts.some(o => o.value === 'serper'),
762 'options: ' + JSON.stringify(c.opts.map(o => o.value)));
763 check('the choosable engines are exactly brave, exa and tavily',
764 JSON.stringify(choosable.slice().sort()) === JSON.stringify(['brave', 'exa', 'tavily']),
765 'choosable: ' + JSON.stringify(choosable));
766 check('the configured engine is the one selected',
767 c.engineValue === 'brave', 'selected ' + JSON.stringify(c.engineValue));
768 }
769 check('the Settings card no longer draws the engine as well',
770 !/Engine for credit searches/.test(c.setText),
771 'the same knob has two editors, which disagree the moment either saves');
772 check('the Settings card still draws its other knobs',
773 /Charge to relay a search/.test(c.setText),
774 'the promotion took the whole group with it');
775
776 // Nothing has been written yet, so nothing may have been posted.
777 check('drawing the card sends no POST', sent(calls).length === 0,
778 JSON.stringify(sent(calls)));
779
780 // ── The wrong paste ─────────────────────────────
781 // The commonest one: an inference key where a search key goes.
782 // allowlist secret -- a fixture shaped like the mistake it stands for.
783 const WRONG = 'sk-ant-api03-not-a-brave-key';
784 await pasteKey(page, 'brave_key', WRONG);
785 const afterWrong = await readCard(page);
786 const said = afterWrong.rowMsgs.join(' | ');
787 check('a wrong prefix is refused before the key leaves the browser',
788 sent(calls).length === 0, 'posted: ' + JSON.stringify(sent(calls)));
789 check('the refusal names the key that was expected',
790 /Brave Search key/.test(said), said);
791 check('the refusal names the prefix it wanted',
792 /BSA/.test(said), said);
793 check('the refusal shows the start of what was pasted',
794 /sk-ant/.test(said), said);
795 check('the refusal does not echo the whole paste',
796 !/not-a-brave-key/.test(said), said);
797 check('the refusal says nothing was sent',
798 /nothing has been sent/i.test(said), said);
799 const kept = await page.inputValue('.admin-prov-key[data-key="brave_key"] input.admin-set-input');
800 check('the refused paste is left in the field to be corrected',
801 kept === WRONG, JSON.stringify(kept));
802
803 // A key with no declared prefix accepts anything shaped like a key,
804 // which is the other half of honouring the registry.
805 await pasteKey(page, 'exa_key', 'anything-goes-here');
806 check('a credential with no declared prefix is not refused for its prefix',
807 sent(calls).some(p => p.view === 'secrets' && p.body && p.body.key === 'exa_key'),
808 'posted: ' + JSON.stringify(sent(calls).map(p => p.body && p.body.key)));
809
810 // Interior whitespace, which secrets.rs refuses and says why.
811 await pasteKey(page, 'tavily_key', 'tvly-abc def');
812 const afterSpace = (await readCard(page)).rowMsgs.join(' | ');
813 check('a key broken by a space is refused, and the message says which mistake',
814 /spaces or line breaks/.test(afterSpace), afterSpace);
815
816 await page.close();
817 } else {
818 check('leg one ran', false, 'the Providers card never became visible');
819 }
820 }
821
822 // ── Saving, and what the confirmation may claim ─────────
823 {
824 // A gateway that stored the key and said nothing about a provider: the
825 // search case, where only the shape was ever checked.
826 const { page, calls } = await openStub(browser, {
827 role: 'owner',
828 onPost: (view, body) => ({ ok: true, secrets: stubSecrets({
829 brave_key: { set: true, hint: '…dead', overridden: true,
830 set_by: 'acct_test', set_at: 1754899000 } }) }),
831 });
832 if (await showProviders(page, 'saving')) {
833 await pasteKey(page, 'brave_key', 'BSA-a-well-formed-key');
834 const c = await readCard(page);
835 const said = c.said.join(' | ');
836 check('the key was sent once the shape was right',
837 sent(calls).some(p => p.body && p.body.key === 'brave_key'
838 && p.body.value === 'BSA-a-well-formed-key'),
839 JSON.stringify(sent(calls)));
840 check('the confirmation says the key is in use',
841 /in use/i.test(said), said);
842 check('the confirmation says no restart is needed',
843 /no restart/i.test(said) && !/restart the gateway/i.test(said), said);
844 check('the confirmation does not claim a provider accepted it',
845 !/accepted by the provider/i.test(said), said);
846 check('the confirmation says the shape is all that was checked',
847 /shape/i.test(said), said);
848 check('the row now reads as set, at the new tail',
849 /…dead/.test(c.text), 'the listing was not redrawn from the reply');
850 const emptied = await page.inputValue('.admin-prov-key[data-key="brave_key"] input.admin-set-input');
851 check('the field is cleared and holds no copy of the key', emptied === '',
852 JSON.stringify(emptied));
853 check('the planted value is still nowhere after a save', !c.planted);
854 await page.close();
855 }
856 }
857 {
858 // And the inference case, where the gateway DID ask the host: the reply
859 // carries a pool balance, and only then may the wording claim a check.
860 const { page } = await openStub(browser, {
861 role: 'owner',
862 onPost: () => ({ ok: true, pool_minor: 796, secrets: stubSecrets({
863 openrouter_key: { set: true, hint: '…9999', overridden: true,
864 set_by: 'acct_test', set_at: 1754899000 } }) }),
865 });
866 if (await showProviders(page, 'a checked key')) {
867 await pasteKey(page, 'openrouter_key', 'sk-or-v1-0123456789abcdef');
868 const said = (await readCard(page)).said.join(' | ');
869 check('a reply carrying a pool balance is reported as the provider accepting it',
870 /accepted by the provider/i.test(said), said);
871 check('the balance the check found is stated',
872 /7\.96/.test(said), said);
873 check('and that confirmation also says no restart',
874 /no restart/i.test(said) && !/restart the gateway/i.test(said), said);
875 await page.close();
876 }
877 }
878
879 // ── An engine the pulldown may not offer ────────────────
880 {
881 const { page } = await openStub(browser, { role: 'owner', engine: 'serper' });
882 if (await showProviders(page, 'serper configured')) {
883 const c = await readCard(page);
884 check('serper in configuration still puts no serper in the pulldown',
885 c.opts && !c.opts.some(o => o.value === 'serper'),
886 JSON.stringify(c.opts && c.opts.map(o => o.value)));
887 check('serper in configuration is not silently replaced by the first option',
888 c.engineValue === '', 'the pulldown selected ' + JSON.stringify(c.engineValue));
889 check('the card names the engine that is running',
890 /serper/.test(c.text), 'an unofferable engine was hidden rather than named');
891 check('and says why it may not be chosen',
892 /resells Google/.test(c.text) && /own key/.test(c.text),
893 'the refusal gave no reason');
894 await page.close();
895 }
896 }
897
898 // ── A viewer ────────────────────────────────────────────
899 {
900 const { page, calls } = await openStub(browser, { role: 'viewer' });
901 if (await showProviders(page, 'viewer')) {
902 const c = await readCard(page);
903 const keys = c.groups.reduce((a, g) => a.concat(g.keys), []);
904 check('a viewer is shown no credential at all', keys.length === 0,
905 'saw ' + JSON.stringify(keys));
906 check('a viewer is told the keys are an owner\'s, not that there are none',
907 c.noteShown && /owner/i.test(c.note), JSON.stringify(c.note));
908 check('a viewer still sees which engine credits searches run on',
909 /Brave/.test(c.text), 'the engine was hidden with the keys');
910 check('a viewer gets no control over the engine',
911 !c.opts, 'a viewer was given the pulldown');
912 check('the planted value reaches a viewer by no route', !c.planted);
913 check('a viewer\'s console never asks for the keys',
914 !calls.some(c => c.view === 'secrets'),
915 'asked for ' + JSON.stringify(calls.map(c => c.view)));
916 await page.close();
917 }
918 }
919
920 // ── What search costs, and on which engine ──────────────
921 //
922 // The Providers card above is where a key is set. This is where an operator
923 // finds out what it is costing him, which is the other half of "can I manage
924 // this and keep an eye on it" and was the half that did not exist.
925 {
926 const { page } = await openStub(browser, { role: 'owner' });
927 if (await showOverview(page, 'consumption')) {
928 const c = await readConsumption(page);
929
930 // Every category the gateway names has a segment of its own. Search
931 // is the one that prompted this; storage and infer are the same
932 // defect, three weeks older, and they are checked by name so that
933 // fixing one and not the others reads as a failure.
934 for (const k of CONSUME_KEYS) {
935 check(`${k} spend is drawn as a category of its own`,
936 c.segs.includes(k), 'the stack drew ' + JSON.stringify(c.segs));
937 }
938 check('the legend names search, stored files and inference',
939 /Search/.test(c.legend) && /Stored files/.test(c.legend)
940 && /Inference/.test(c.legend), JSON.stringify(c.legend));
941 check('every legend swatch is keyed to the category it stands for',
942 CONSUME_KEYS.every(k => c.swatches.includes(k)),
943 JSON.stringify(c.swatches));
944 // The value, not just the segment: a search day is 300 minor units
945 // in the fixture, and it has to read as search money.
946 check('a search segment says what it cost',
947 c.titles.some(t => /Search: [^0-9]*3\.00/.test(t)),
948 JSON.stringify(c.titles.filter(t => /Search/.test(t))));
949 check('paid search is no longer drawn as Other',
950 !c.titles.some(t => /Other: [^0-9]*3\.00/.test(t)),
951 JSON.stringify(c.titles.filter(t => /Other/.test(t))));
952 // Nothing was lost on the way: with both halves current, there is
953 // nothing left over to draw.
954 check('nothing in the window is unaccounted for',
955 !c.segs.includes('unlisted') && !/Not accounted for/.test(c.legend),
956 'the page cannot name a category the gateway counted');
957
958 // The breakdown by engine, which is what a vendor cap is set on.
959 check('the search spend is broken down by engine', c.engPresent,
960 'no #admin-search-engines under the chart');
961 check('the breakdown sits in the same card as the chart it splits',
962 c.inSameCard, 'it was drawn somewhere else, or nowhere');
963 check('the engines are listed biggest spender first',
964 JSON.stringify(c.engRows.map(r => r.engine)) === JSON.stringify(['brave', 'exa']),
965 JSON.stringify(c.engRows.map(r => r.engine)));
966 const brave = c.engRows.find(r => r.engine === 'brave') || { cells: [] };
967 check('the engine row counts queries, which is what a vendor cap counts',
968 /^40 searches$/.test((brave.cells[1] || '').trim()),
969 JSON.stringify(brave.cells));
970 check('the engine row says what that engine cost',
971 /4\.00/.test(brave.cells[2] || ''), JSON.stringify(brave.cells));
972 check('the engine row says what share of search it is',
973 /83%/.test(brave.cells[3] || ''), JSON.stringify(brave.cells));
974 check('the breakdown totals the window in queries and in money',
975 /44 searches/.test(c.engText) && /4\.80/.test(c.engText),
976 JSON.stringify(c.engText));
977 await page.close();
978 }
979 }
980 {
981 // A gateway that counted more in a day than the page can name: the shape
982 // of the original defect, arriving from the other direction. The
983 // remainder must be DRAWN, not subtracted -- a short bar is a chart that
984 // lies quietly, which is how three weeks went by.
985 const { page } = await openStub(browser, { role: 'owner',
986 consume: { lastTotal: 340 } }); // named parts sum to 240
987 if (await showOverview(page, 'a category this page cannot name')) {
988 const c = await readConsumption(page);
989 check('spend in a category the page cannot name is still drawn',
990 c.segs.includes('unlisted'), 'the stack drew ' + JSON.stringify(c.segs));
991 check('and it is named as unaccounted for rather than as Other',
992 /Not accounted for/.test(c.legend), JSON.stringify(c.legend));
993 check('the remainder is the gateway\'s total less what was drawn',
994 c.titles.some(t => /Not accounted for: [^0-9]*1\.00/.test(t)),
995 JSON.stringify(c.titles.filter(t => /accounted/.test(t))));
996 await page.close();
997 }
998 }
999 {
1000 // An operator who has just set a paid key and had no searches yet. The
1001 // breakdown must say so: "nobody has searched" and "this panel is
1002 // broken" look identical if it draws nothing at all.
1003 const { page } = await openStub(browser, { role: 'owner',
1004 consume: { empty: true } });
1005 if (await showOverview(page, 'no spend yet')) {
1006 const c = await readConsumption(page);
1007 check('an empty window still draws the breakdown, with its heading',
1008 c.engPresent && /Search by engine/.test(c.engText),
1009 JSON.stringify(c.engText));
1010 check('an empty window says there was no search spend',
1011 /No search spend in the last 30 days/.test(c.engText),
1012 JSON.stringify(c.engText));
1013 check('and no engine row is invented for it',
1014 c.engRows.length === 0, JSON.stringify(c.engRows));
1015 await page.close();
1016 }
1017 }
1018
1019 // ── A busy store, and the difference between nothing and zero ──
1020 //
1021 // The gateway answers a view it could only partly read with the figures it
1022 // GOT and the names of the ones it did not, deliberately omitting the rest
1023 // rather than sending zeroes. Everything below is the console's half of
1024 // that bargain, and the two halves cancel out if either is got wrong: a
1025 // gateway that omits a figure the console renders as `0` has bought
1026 // nothing, and a console that discards the reply keeps the reason and loses
1027 // the readings.
1028 {
1029 // Complete answers first, so the absences below are read against
1030 // something. Same store, same card, everything arriving.
1031 const { page } = await openStub(browser, { role: 'owner' });
1032 if (await showCapacity(page, 'every figure read')) {
1033 const c = await readCapacity(page);
1034 check('a reading that arrived is drawn as a proportion of its ceiling',
1035 /3 GiB of 10 GiB/.test(c.storage.head) && c.storage.rail === 'proportion',
1036 JSON.stringify([c.storage.head, c.storage.rail]));
1037 check('a complete answer marks nothing as missing',
1038 !c.storage.marked && !c.egress.marked && c.err === '',
1039 JSON.stringify([c.storage.marked, c.egress.marked, c.err]));
1040 check('and the card says how many accounts are storing data',
1041 /12 accounts storing data/.test(c.hint), JSON.stringify(c.hint));
1042 await page.close();
1043 }
1044 }
1045 {
1046 // A gateway holding nothing at all. Every figure here is a real reading
1047 // whose value happens to be zero, and it has to keep reading as zero:
1048 // the whole change is worthless if it makes an idle gateway look like a
1049 // broken one.
1050 const { page } = await openStub(browser, { role: 'owner',
1051 views: { capacity: stubCapacity({ empty: true }) } });
1052 if (await showCapacity(page, 'a gateway holding nothing')) {
1053 const c = await readCapacity(page);
1054 check('a genuine zero still reads as zero, not as an absence',
1055 /0 B stored/.test(c.storage.head) && !c.storage.head.includes('—'),
1056 JSON.stringify(c.storage.head));
1057 check('a genuine zero is not marked as unread',
1058 !c.storage.marked && !c.egress.marked && c.storage.rail === 'nolimit',
1059 JSON.stringify([c.storage.marked, c.egress.marked, c.storage.rail]));
1060 check('a ceiling that is genuinely unset still says so',
1061 /No cap is set/.test(c.storage.words), c.storage.words.slice(0, 90));
1062 check('an idle month reads as no traffic rather than as no answer',
1063 /0 B sent/.test(c.egress.head), JSON.stringify(c.egress.head));
1064 const k = await readKpis(page);
1065 check('a zero account count reads as 0 and is not marked',
1066 tile(k, 'Accounts').val === '0' && tile(k, 'Accounts').marked === false,
1067 JSON.stringify(tile(k, 'Accounts')));
1068 await page.close();
1069 }
1070 }
1071 {
1072 // And the store that stopped answering part way through: `capacity` and
1073 // `summary` in part, `geo` outright.
1074 const { page } = await openStub(browser, { role: 'owner', views: {
1075 capacity: stubCapacityPartial(),
1076 summary: stubSummaryPartial(),
1077 geo: { ok: false, error: GEO_FAILED },
1078 } });
1079 if (await showCapacity(page, 'a store that stopped answering')) {
1080 const c = await readCapacity(page);
1081
1082 // Drawn, not discarded. This is the whole point: the figure the
1083 // store DID give is on the screen beside the one it did not.
1084 check('a figure that arrived is still drawn when its neighbour did not',
1085 /3 GiB stored/.test(c.storage.head), JSON.stringify(c.storage.head));
1086 check('a figure that did not arrive is not drawn as zero',
1087 !/0 B/.test(c.egress.head) && c.egress.head.includes('—'),
1088 JSON.stringify(c.egress.head));
1089 // The dangerous one. An unread ceiling rendered as `0` is the
1090 // console announcing there is no limit while uploads are being
1091 // refused at one.
1092 check('a ceiling that could not be read is not reported as no ceiling',
1093 !/No cap is set/.test(c.storage.words)
1094 && /ceiling could not be read/.test(c.storage.words),
1095 c.storage.words.slice(0, 110));
1096 check('an allowance that could not be read is not reported as no allowance',
1097 !/No plan allowance is recorded/.test(c.egress.words)
1098 && /allowance could not be read/.test(c.egress.words),
1099 c.egress.words.slice(0, 110));
1100 // An empty rail means one of two opposite things, so the two are
1101 // drawn apart -- an operator sets a limit, or goes and looks at the
1102 // store, and he chooses by looking at this.
1103 check('an unfillable bar says which silence it is',
1104 c.storage.rail === 'unread' && c.egress.rail === 'unread',
1105 JSON.stringify([c.storage.rail, c.egress.rail]));
1106 check('the blocks with a gap in them are visibly marked',
1107 c.storage.marked && c.egress.marked,
1108 JSON.stringify([c.storage.marked, c.egress.marked]));
1109 // Which figure, and why -- not "something failed".
1110 check('the card names the figure it could not read',
1111 /metered egress/.test(c.err), JSON.stringify(c.err));
1112 check('and gives the store\'s own reason for it',
1113 /timed out after 6s/.test(c.err), JSON.stringify(c.err));
1114 check('a partial answer is stated calmly, not as an outright failure',
1115 c.errCalm, 'the card\'s line is in the register kept for a dead view');
1116 check('the reading that DID arrive still fills the hint',
1117 /12 accounts storing data/.test(c.hint), JSON.stringify(c.hint));
1118 }
1119 if (await showOverview(page, 'a store that stopped answering')) {
1120 const k = await readKpis(page);
1121 const acct = tile(k, 'Accounts');
1122 check('a tile whose figure arrived is drawn in full',
1123 acct.val === '3,467' && /\+12 in 24h/.test(acct.sub) && !acct.marked,
1124 JSON.stringify(acct));
1125 for (const lbl of ['Credits outstanding', 'Active Pro', 'Sync storage']) {
1126 const t = tile(k, lbl);
1127 check(`${lbl}: an unread figure reads as absent, not as zero`,
1128 t.val === '—' && t.marked === true, JSON.stringify(t));
1129 }
1130 check('no unread tile shows a currency amount it never received',
1131 !k.tiles.some(t => t.marked && /[0-9]/.test(t.val)),
1132 JSON.stringify(k.tiles.filter(t => t.marked).map(t => t.label + '=' + t.val)));
1133 // The strip: which view, which figure, and why. A console that
1134 // summarised this to "could not load" would be showing an operator
1135 // less than the reply it was holding.
1136 check('the strip names the view that answered in part',
1137 /Read in part:/.test(k.strip) && /summary/.test(k.strip),
1138 JSON.stringify(k.strip));
1139 check('the strip names which figure went missing',
1140 /Credits outstanding, revenue and consumption could not be read/.test(k.strip),
1141 JSON.stringify(k.strip));
1142 check('the strip carries the store\'s own reason, in full',
1143 /41 of 3467 account ledgers were read/.test(k.strip)
1144 && /timed out after 6s/.test(k.strip), JSON.stringify(k.strip));
1145 check('a view that failed outright is quoted, not merely named',
1146 /The 'geo' view failed/.test(k.strip), JSON.stringify(k.strip));
1147 check('and the strip still says the rest is current',
1148 /The rest is current/.test(k.strip), JSON.stringify(k.strip));
1149 }
1150 await page.close();
1151 }
1152
1153 // ── Leg two: the real registry ──────────────────────────
1154 //
1155 // Everything above is the console's own behaviour. Whether a credential can
1156 // be read back is the GATEWAY's property, and a stub of my own writing
1157 // cannot testify to it.
1158 {
1159 let stray = false;
1160 try { stray = (await fetch(`${GW_URL}/api/health`)).ok; } catch (e) {}
1161 if (stray) {
1162 check(`no gateway is already running on :${GW_PORT}`, false,
1163 'stop it first (pkill -f release/daimond_gateway); this leg pins an owner');
1164 } else {
1165 check('the gateway starts', await startGateway(null));
1166
1167 const ownerCtx = await browser.newContext({ viewport: { width: 1400, height: 1100 } });
1168 const otherCtx = await browser.newContext({ viewport: { width: 1400, height: 1100 } });
1169 const ownerPage = await ownerCtx.newPage();
1170 const otherPage = await otherCtx.newPage();
1171 const owner = await signInFresh(ownerPage, APP);
1172 const other = await signInFresh(otherPage, APP);
1173 check('two accounts sign in', !!owner && !!other && owner !== other);
1174 check('the gateway restarts with one of them as owner', await startGateway(owner));
1175
1176 const listing = await api(ownerPage, 'secrets');
1177 const rows = (listing.j && listing.j.secrets) || [];
1178 check('the owner may read the registry', listing.status === 200 && rows.length > 0,
1179 'status ' + listing.status);
1180
1181 // The registry half of the contract, §6. A FAIL here is lane
1182 // gateway's half not having landed, not a console defect -- and it
1183 // is reported rather than skipped, because a Search group with no
1184 // rows in it is exactly what an operator would be looking at.
1185 // THREE, not four. An operator serper key was registered by the
1186 // contract and removed on purpose: the credits tier may never spend
1187 // serper, and a BYOK key is never stored -- so the row would be a live
1188 // third-party credential at rest that no code path can reach. Given
1189 // what the write path was nearly doing with credentials nobody had
1190 // thought hard about, a dead one is the wrong thing to leave lying
1191 // about. Asserted as an absence below, so it cannot creep back.
1192 for (const k of ['brave_key', 'exa_key', 'tavily_key']) {
1193 check(`the gateway registers ${k} on /api/web/search`,
1194 rows.some(r => r.key === k && r.route === '/api/web/search'),
1195 'registered: ' + JSON.stringify(rows.map(r => r.route + ':' + r.key)));
1196 }
1197
1198 // No value, from the gateway's own mouth. Asserted over the field
1199 // NAMES rather than over one expected shape: a value added later
1200 // under any name at all fails this.
1201 const allowed = ['route', 'key', 'label', 'help', 'prefix', 'set', 'hint',
1202 'overridden', 'configured', 'set_by', 'set_at'];
1203 const extra = [];
1204 rows.forEach(r => Object.keys(r).forEach(f => {
1205 if (!allowed.includes(f)) extra.push(r.key + '.' + f);
1206 }));
1207 check('the registry answers with presence and provenance and nothing else',
1208 extra.length === 0,
1209 'fields nobody here expected, each needing the same question asked of it '
1210 + '-- does this carry any part of a value? ' + JSON.stringify(extra));
1211 check('no hint is long enough to be a key',
1212 rows.every(r => !r.hint || (r.hint.length <= 8 && r.hint.indexOf('…') === 0)),
1213 JSON.stringify(rows.map(r => r.hint)));
1214
1215 // The wrong paste, refused BEFORE it is stored. Run against every
1216 // credential that declares a prefix, so the search keys are covered
1217 // the moment they declare one, and the inference key covers the
1218 // mechanism meanwhile. A wrong prefix is caught by `shaped_like`
1219 // ahead of any call to the provider, so nothing here reaches a
1220 // network.
1221 const prefixed = rows.filter(r => r.prefix);
1222 check('at least one credential declares a prefix to be checked against',
1223 prefixed.length > 0,
1224 'no registered credential declares one, so nothing tests the refusal');
1225 for (const r of prefixed) {
1226 const wrong = 'zz-wrong-' + r.key;
1227 const res = await api(ownerPage, 'secrets',
1228 { route: r.route, key: r.key, value: wrong });
1229 const msg = (res.j && res.j.error) || '';
1230 check(`${r.key}: a wrong prefix is refused`, res.status === 400,
1231 'status ' + res.status + ' ' + msg);
1232 check(`${r.key}: the refusal names the prefix it wanted`,
1233 msg.includes(r.prefix), msg);
1234 const after = await api(ownerPage, 'secrets');
1235 const now = ((after.j && after.j.secrets) || []).find(x => x.key === r.key) || {};
1236 check(`${r.key}: the refused value was not stored`,
1237 now.overridden === false, JSON.stringify(now));
1238 }
1239
1240 // The other half of "a viewer cannot read a value back": a viewer
1241 // cannot reach the view at all, so there is no value to redact.
1242 check('the owner may grant viewer',
1243 (await api(ownerPage, 'operators',
1244 { account_id: other, role: 'viewer' })).status === 200);
1245 const asViewer = await api(otherPage, 'secrets');
1246 check('a viewer is refused the credentials view outright',
1247 asViewer.status === 403, 'status ' + asViewer.status);
1248 check('a viewer may still read the knobs',
1249 (await api(otherPage, 'settings')).status === 200);
1250
1251 await ownerCtx.close();
1252 await otherCtx.close();
1253 }
1254 }
1255} catch (e) {
1256 check('the run completed without throwing', false, e.message);
1257} finally {
1258 await browser.close();
1259 cleanup();
1260}
1261
1262console.log('');
1263console.log(`passed ${ok.length}, failed ${bad.length}` + (BREAK ? ` [--break ${BREAK}]` : ''));
1264if (bad.length) { console.log('failures:'); bad.forEach(b => console.log(' - ' + b)); }
1265process.exit(bad.length ? 1 : 0);