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 | |
| 95 | import os from 'node:os'; |
| 96 | import path from 'node:path'; |
| 97 | import { spawn } from 'node:child_process'; |
| 98 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 99 | import { requireFreshGateway, GWCWD } from './gwbin.mjs'; |
| 100 | import { signInFresh } from './session.mjs'; |
| 101 | import { 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. |
| 108 | import './display.mjs'; |
| 109 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 110 | const ROOT = path.join(HERE, '..'); |
| 111 | const GWDIR = path.join(ROOT, 'gateway'); |
| 112 | const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`; |
| 113 | |
| 114 | const PW = process.env.DAIMOND_PW |
| 115 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 116 | const CHROME = process.env.DAIMOND_CHROME |
| 117 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 118 | |
| 119 | const BREAK = (() => { |
| 120 | const i = process.argv.indexOf('--break'); |
| 121 | return i >= 0 ? (process.argv[i + 1] || 'value') : null; |
| 122 | })(); |
| 123 | |
| 124 | const ok = [], bad = []; |
| 125 | const check = (name, pass, detail) => { |
| 126 | (pass ? ok : bad).push(name); |
| 127 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' -- ' + detail : '')); |
| 128 | }; |
| 129 | const 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. |
| 134 | requireFreshGateway(); |
| 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. |
| 142 | const 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. |
| 153 | function 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. |
| 184 | function 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. |
| 214 | const 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. |
| 223 | function 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. |
| 256 | const 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. |
| 259 | const 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`. |
| 263 | const 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. |
| 271 | function 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. |
| 306 | function 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. |
| 326 | function 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. |
| 344 | const 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. |
| 352 | function 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. |
| 383 | function 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. |
| 425 | function 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. |
| 435 | async 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. |
| 450 | function 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. |
| 494 | async 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. |
| 509 | function 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. |
| 548 | async 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. |
| 569 | function 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. |
| 584 | const 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. |
| 593 | function 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. |
| 626 | async 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 ────────────────────── |
| 634 | const procs = []; |
| 635 | function launch(cmd, args, opts) { const p = spawn(cmd, args, opts); procs.push(p); return p; } |
| 636 | async 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 | } |
| 644 | function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } } |
| 645 | |
| 646 | let gw = null; |
| 647 | async 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. |
| 659 | async 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 | |
| 692 | const { 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. |
| 697 | const browser = await chromium.launch({ executablePath: CHROME, headless: false, |
| 698 | args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'] }); |
| 699 | |
| 700 | try { |
| 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 | |
| 1262 | console.log(''); |
| 1263 | console.log(`passed ${ok.length}, failed ${bad.length}` + (BREAK ? ` [--break ${BREAK}]` : '')); |
| 1264 | if (bad.length) { console.log('failures:'); bad.forEach(b => console.log(' - ' + b)); } |
| 1265 | process.exit(bad.length ? 1 : 0); |