oxedyne/daimond/dev/verify_admin.mjs
23.3 KiB, 1 run
created by r2519314175:225, 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_admin.mjs — the operator console: token auth, the /api/admin data |
| 2 | // feed, and the dashboard (KPIs, revenue + consumption charts, world map, |
| 3 | // tables) rendering end to end. |
| 4 | // |
| 5 | // Self-contained: it spawns the release gateway on :9002 (reading the operator token |
| 6 | // from gateway/keys/admin_token.txt) and dev/serve.mjs (DAIMOND_PORT, default 8777), |
| 7 | // seeds a handful of accounts and webhook-credited top-ups so the views have something |
| 8 | // to draw, then drives the real /console/ page in a real browser. |
| 9 | // |
| 10 | // node dev/verify_admin.mjs |
| 11 | // |
| 12 | // Asserts the auth contract (no token → 401, wrong token → 401, right token → |
| 13 | // 200 summary), then screenshots the dashboard desktop + mobile into dev/shots. |
| 14 | |
| 15 | import fs from 'node:fs'; |
| 16 | import os from 'node:os'; |
| 17 | import path from 'node:path'; |
| 18 | import crypto from 'node:crypto'; |
| 19 | import { spawn } from 'node:child_process'; |
| 20 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 21 | import { signInFresh } from './session.mjs'; |
| 22 | import { requireFreshGateway, procLog, GWCWD } from './gwbin.mjs'; |
| 23 | import { GW_PORT, GW_URL } from './ports.mjs'; |
| 24 | |
| 25 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 26 | const ROOT = path.join(HERE, '..'); |
| 27 | const GWDIR = path.join(ROOT, 'gateway'); |
| 28 | const SHOTS = path.join(HERE, 'shots'); |
| 29 | /// What the gateway says while this runs. An admin view answering 500 says only |
| 30 | /// that something went wrong; `app_main` logs the reason beside it, and this is |
| 31 | /// where that reason is kept. |
| 32 | const GW_LOG = procLog('verify_admin'); |
| 33 | /// And the dev server, which fails the same silent way when its port is taken. |
| 34 | const SERVE_LOG = procLog('verify_admin', 'serve'); |
| 35 | // The world's dev server -- see dev/world.sh. Kept inline rather than imported, |
| 36 | // so this stays standalone and does not load the harness. |
| 37 | const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`; |
| 38 | |
| 39 | const PW = process.env.DAIMOND_PW |
| 40 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 41 | const CHROME = process.env.DAIMOND_CHROME |
| 42 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 43 | |
| 44 | const ok = [], bad = []; |
| 45 | /// Every `/api/admin` response the console received, as `{view, status}`. |
| 46 | const adminResp = []; |
| 47 | const check = (name, pass, detail) => { |
| 48 | (pass ? ok : bad).push(name); |
| 49 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 50 | }; |
| 51 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 52 | |
| 53 | const WHSEC = fs.readFileSync(path.join(GWDIR, 'keys/stripe/sandbox/whsec'), 'utf8').trim(); |
| 54 | |
| 55 | const procs = []; |
| 56 | function launch(cmd, args, opts) { |
| 57 | const p = spawn(cmd, args, opts); |
| 58 | procs.push(p); |
| 59 | return p; |
| 60 | } |
| 61 | async function waitFor(fn, ms = 20000, gap = 300) { |
| 62 | const t0 = Date.now(); |
| 63 | for (;;) { |
| 64 | try { if (await fn()) return true; } catch (e) {} |
| 65 | if (Date.now() - t0 > ms) return false; |
| 66 | await sleep(gap); |
| 67 | } |
| 68 | } |
| 69 | function cleanup() { |
| 70 | for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } |
| 71 | } |
| 72 | /// What the processes this run started were saying while it failed. |
| 73 | function saidWhat() { GW_LOG.report(); SERVE_LOG.report(); } |
| 74 | |
| 75 | // ── Seeding via the real API ──────────────────────────────── |
| 76 | // Register an account by proving possession of a fresh Ed25519 device key, |
| 77 | // exactly as the browser does. |
| 78 | async function register(country) { |
| 79 | const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); |
| 80 | const jwk = publicKey.export({ format: 'jwk' }); |
| 81 | const pub = jwk.x; // base64url raw 32-byte public key |
| 82 | const ts = Math.floor(Date.now() / 1000); |
| 83 | const msg = `daimond-gw-account:v1:${pub}:${ts}`; |
| 84 | const sig = crypto.sign(null, Buffer.from(msg), privateKey).toString('base64'); |
| 85 | const body = { pubkey: pub, alg: 'Ed25519', ts, sig }; |
| 86 | if (country) body.country = country; |
| 87 | const r = await fetch(`${GW_URL}/api/account`, { |
| 88 | method: 'POST', |
| 89 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 90 | body: JSON.stringify(body), |
| 91 | }); |
| 92 | const j = await r.json(); |
| 93 | return j.account_id; |
| 94 | } |
| 95 | |
| 96 | // Credit an account by posting a Stripe-signed checkout.session.completed, the |
| 97 | // same event the gateway trusts to move money. |
| 98 | async function creditTopup(accountId, minor, country, n) { |
| 99 | const payload = JSON.stringify({ |
| 100 | id: `evt_seed_${accountId}_${n}`, |
| 101 | type: 'checkout.session.completed', |
| 102 | data: { object: { |
| 103 | payment_status: 'paid', |
| 104 | amount_total: minor, |
| 105 | customer_details: { address: { country } }, |
| 106 | metadata: { account_id: accountId, product: 'credits', credits_minor: String(minor) }, |
| 107 | } }, |
| 108 | }); |
| 109 | const t = Math.floor(Date.now() / 1000); |
| 110 | const mac = crypto.createHmac('sha256', WHSEC).update(`${t}.${payload}`).digest('hex'); |
| 111 | const r = await fetch(`${GW_URL}/webhook/stripe`, { |
| 112 | method: 'POST', |
| 113 | headers: { 'content-type': 'application/json', 'stripe-signature': `t=${t},v1=${mac}` }, |
| 114 | body: payload, |
| 115 | }); |
| 116 | return r.status; |
| 117 | } |
| 118 | |
| 119 | async function seed() { |
| 120 | const plan = [ |
| 121 | ['US', 5000], ['US', 2000], ['GB', 10000], ['AU', 5000], ['DE', 2000], |
| 122 | ['IN', 1000], ['BR', 5000], ['CA', 2000], ['JP', 1000], ['FR', 5000], |
| 123 | ['', 2000], // unknown-location account |
| 124 | ]; |
| 125 | let credited = 0, made = 0; |
| 126 | for (let i = 0; i < plan.length; i++) { |
| 127 | const [cc, minor] = plan[i]; |
| 128 | const id = await register(cc); |
| 129 | if (!id) continue; |
| 130 | made++; |
| 131 | const st = await creditTopup(id, minor, cc || 'US', 1); |
| 132 | if (st >= 200 && st < 300) credited++; |
| 133 | } |
| 134 | return { made, credited }; |
| 135 | } |
| 136 | |
| 137 | // ── Raw auth contract ─────────────────────────────────────── |
| 138 | // The console is reached with the app's own signed session, so an unauthenticated |
| 139 | // call is the only thing Node can make on its own -- and it must be refused. |
| 140 | async function adminRaw() { |
| 141 | const r = await fetch(`${GW_URL}/api/admin?view=summary`, { |
| 142 | headers: { 'x-daimond-api': '1' }, |
| 143 | }); |
| 144 | let j = null; try { j = await r.json(); } catch (e) {} |
| 145 | return { status: r.status, j }; |
| 146 | } |
| 147 | |
| 148 | // ── Main ──────────────────────────────────────────────────── |
| 149 | (async () => { |
| 150 | // Before anything is spawned: a gateway older than the code under test |
| 151 | // measures a build nobody is shipping, and its absences read as defects. |
| 152 | requireFreshGateway(); |
| 153 | |
| 154 | // 1. Gateway. |
| 155 | // Pinned as an owner by account id, which is not known until an account |
| 156 | // exists -- so this suite starts the gateway twice, as verify_releases does. |
| 157 | let gw = launch(path.join(GWDIR, 'target/release/daimond_gateway'), [], { |
| 158 | cwd: GWCWD, |
| 159 | env: { ...process.env, APP_MODE: 'sandbox' }, |
| 160 | stdio: GW_LOG.stdio, |
| 161 | }); |
| 162 | const gwUp = await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok); |
| 163 | check('gateway starts and answers /api/health', gwUp); |
| 164 | if (!gwUp) { saidWhat(); cleanup(); console.log(`\n${ok.length} passed, ${bad.length} failed`); process.exit(1); } |
| 165 | |
| 166 | // 2. Auth contract: no session, no console. There is no token to try. |
| 167 | const anon = await adminRaw(); |
| 168 | check('no session → 401', anon.status === 401, 'status ' + anon.status); |
| 169 | |
| 170 | // 3. Seed, then confirm the aggregates moved. |
| 171 | const seeded = await seed(); |
| 172 | check('seeding registered accounts and credited top-ups', |
| 173 | seeded.made > 0 && seeded.credited > 0, JSON.stringify(seeded)); |
| 174 | // 4. Dev server + browser. The aggregates are checked from inside the page, |
| 175 | // because the session that may read them lives in the browser. |
| 176 | // Reuse one already up, as the other console verifiers do. This spawned a |
| 177 | // second unconditionally, which died on EADDRINUSE the moment a world server |
| 178 | // held the port -- invisibly, until its output stopped being discarded. |
| 179 | let already = false; |
| 180 | try { already = (await fetch(`${APP}/console/`)).ok; } catch (e) {} |
| 181 | if (!already) launch('node', ['dev/serve.mjs'], { cwd: ROOT, stdio: SERVE_LOG.stdio }); |
| 182 | const serveUp = await waitFor(async () => (await fetch(`${APP}/console/`)).ok, 10000); |
| 183 | check('dev server serves /console/', serveUp); |
| 184 | |
| 185 | if (serveUp) { |
| 186 | const { chromium } = await import(pathToFileURL(PW).href); |
| 187 | const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] }); |
| 188 | const errs = []; |
| 189 | try { |
| 190 | const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } }); |
| 191 | page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); }); |
| 192 | page.on('pageerror', e => errs.push('pageerror: ' + e.message)); |
| 193 | // Every /api/admin round trip the CONSOLE makes, kept so a blank |
| 194 | // panel can be attributed to the endpoint that refused rather than |
| 195 | // to the renderer that had nothing to draw. |
| 196 | page.on('response', r => { |
| 197 | const u = r.url(); |
| 198 | if (u.indexOf('/api/admin') < 0) return; |
| 199 | const m = /[?&]view=([a-z_]+)/.exec(u); |
| 200 | adminResp.push({ view: m ? m[1] : u, status: r.status(), at: Date.now() }); |
| 201 | }); |
| 202 | |
| 203 | // Sign in as the app does, then pin that account as an owner and |
| 204 | // restart, since an owner is named in configuration by account id. |
| 205 | const owner = await signInFresh(page, APP); |
| 206 | check('a fresh account signs in to the gateway', !!owner, owner || 'none'); |
| 207 | try { gw.kill('SIGKILL'); } catch (e) {} |
| 208 | // Wait for the port to actually free before rebinding. A health |
| 209 | // check alone cannot tell the new gateway from an older one still |
| 210 | // holding :9002 -- and when one is, the replacement exits with |
| 211 | // AddrInUse while every check goes on passing against the process |
| 212 | // that has no owner configured. That failure reads as "the owner |
| 213 | // pin does not work", which is a long way from the truth. |
| 214 | check(`port ${GW_PORT} is free for the restart`, |
| 215 | await waitFor(async () => { |
| 216 | try { await fetch(`${GW_URL}/api/health`); return false; } |
| 217 | catch (e) { return true; } |
| 218 | }, 15000)); |
| 219 | gw = launch(path.join(GWDIR, 'target/release/daimond_gateway'), [], { |
| 220 | cwd: GWCWD, |
| 221 | env: { ...process.env, APP_MODE: 'sandbox', DAIMOND_OWNER_ACCOUNTS: owner }, |
| 222 | stdio: GW_LOG.stdio, |
| 223 | }); |
| 224 | check('gateway restarts with that account as owner', |
| 225 | await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok)); |
| 226 | // And prove it is the RIGHT gateway: the account that just signed |
| 227 | // in must now read as an owner. Without this the suite cannot tell |
| 228 | // which process answered. |
| 229 | const who = await page.evaluate(async () => { |
| 230 | const r = await fetch('/api/admin?view=whoami', { credentials: 'same-origin' }); |
| 231 | return await r.json().catch(() => null); |
| 232 | }); |
| 233 | check('the restarted gateway knows that account as the owner', |
| 234 | who && who.role === 'owner', 'role ' + (who && who.role)); |
| 235 | |
| 236 | const agg = await page.evaluate(async () => { |
| 237 | const r = await fetch('/api/admin?view=summary', { credentials: 'same-origin' }); |
| 238 | return { status: r.status, j: await r.json().catch(() => null) }; |
| 239 | }); |
| 240 | check('summary counts the seeded accounts', |
| 241 | agg.j && agg.j.accounts >= seeded.made, |
| 242 | 'status ' + agg.status + ', accounts ' + (agg.j && agg.j.accounts) |
| 243 | + (agg.j && agg.j.error ? ', ' + agg.j.error : '')); |
| 244 | const revTotal = ((agg.j && agg.j.revenue) || []).reduce((a, r) => a + (r.total || 0), 0); |
| 245 | check('summary revenue reflects the credited top-ups', revTotal > 0, 'revenue ' + revTotal); |
| 246 | const geoJson = await page.evaluate(async () => { |
| 247 | const r = await fetch('/api/admin?view=geo', { credentials: 'same-origin' }); |
| 248 | return await r.json().catch(() => null); |
| 249 | }); |
| 250 | check('geo view returns per-country rows', |
| 251 | geoJson && geoJson.ok && Array.isArray(geoJson.countries) && geoJson.countries.length > 1, |
| 252 | ((geoJson && geoJson.countries) || []).length + ' countries'); |
| 253 | errs.length = 0; // the pre-owner 403s were asked for |
| 254 | |
| 255 | // The stopwatch starts BEFORE the page is asked for, not after the |
| 256 | // dashboard appears. The views now answer from one shared reading of |
| 257 | // the store rather than scanning it as they are asked, so all four |
| 258 | // can land inside the poll interval of the `waitForSelector` above -- |
| 259 | // and a t0 taken after that interval sits AFTER every response the |
| 260 | // comparison below is made of, leaving it nothing to compare and |
| 261 | // failing a console that drew itself in twenty-seven milliseconds. |
| 262 | const t0 = Date.now(); |
| 263 | await page.goto(`${APP}/console/`, { waitUntil: 'domcontentloaded' }); |
| 264 | await page.waitForSelector('#admin-app:not([hidden])', { timeout: 10000 }); |
| 265 | // Let the four parallel view fetches land and draw. Timed, because |
| 266 | // how LONG the first tile takes is the measurement that separates |
| 267 | // "the dashboard is broken" from "the dashboard is waiting", and |
| 268 | // those two have entirely different fixes. |
| 269 | await page.waitForFunction(() => |
| 270 | document.querySelectorAll('#admin-kpis .admin-kpi').length >= 4, null, { timeout: 30000 }) |
| 271 | .catch(() => {}); |
| 272 | const kpiAt = Date.now(); |
| 273 | // Wait for all four to have answered, so "the slowest" below is the |
| 274 | // real slowest and not merely the slowest so far. |
| 275 | const VIEWS = ['summary', 'revenue', 'consumption', 'geo']; |
| 276 | await waitFor(async () => |
| 277 | VIEWS.every(v => adminResp.some(r => r.at >= t0 && r.view === v)), 30000, 200); |
| 278 | await sleep(800); |
| 279 | |
| 280 | // A tile count on its own says the dashboard is empty and not one |
| 281 | // word about why, and `renderKpis` appends its six unconditionally |
| 282 | // -- so zero means it was never reached, which is a statement about |
| 283 | // the FETCHES and not about the tiles. The console already writes |
| 284 | // that reason into its own status strip for the operator; read it, |
| 285 | // and read the admin responses that did not come back 200, so the |
| 286 | // failure names the endpoint instead of the symptom. |
| 287 | const why = await page.evaluate(() => |
| 288 | (document.getElementById('admin-status') || {}).textContent || ''); |
| 289 | const seen = adminResp.map(r => r.view + ':' + r.status).join(' ') || 'NO /api/admin CALLS'; |
| 290 | const detail = s => s + (why ? ' | console says: ' + why : '') + ' | admin calls: ' + seen; |
| 291 | |
| 292 | const kpis = await page.evaluate(() => document.querySelectorAll('#admin-kpis .admin-kpi').length); |
| 293 | check('dashboard renders KPI tiles', kpis >= 6, detail(kpis + ' tiles')); |
| 294 | |
| 295 | // EACH PANEL DRAWS WHEN ITS OWN DATA LANDS, asserted as a property |
| 296 | // rather than waited out. The tiles are made from `summary` alone, |
| 297 | // so the moment to measure them against is when SUMMARY answered -- |
| 298 | // not when the slowest of the four did. `refreshAll` used to await |
| 299 | // all four before drawing any, and `summary` is the small prompt one |
| 300 | // while `geo` and `consumption` scan every account's ledger over |
| 301 | // thirty days: on this seeded database that gap is several seconds, |
| 302 | // and it is the whole difference between the two designs. |
| 303 | // |
| 304 | // Comparing against the SLOWEST instead would be worthless. Under |
| 305 | // the barrier the tiles appear the instant the slowest lands, so |
| 306 | // "no later than the slowest" is true of the broken code too. This |
| 307 | // comparison is the one that goes red on it, and it cannot flake: |
| 308 | // where all four happen to land together, summary IS the slowest and |
| 309 | // the tolerance covers the render. |
| 310 | const answered = adminResp.filter(r => r.at >= t0 && VIEWS.indexOf(r.view) >= 0); |
| 311 | const at = v => (answered.find(r => r.view === v) || {}).at || 0; |
| 312 | const slowest = answered.length ? Math.max.apply(null, answered.map(r => r.at)) : 0; |
| 313 | check('the tiles are drawn when summary lands, not when the slowest view does', |
| 314 | answered.length >= VIEWS.length && at('summary') > 0 && kpiAt <= at('summary') + 1500, |
| 315 | 'tiles at +' + (kpiAt - t0) + 'ms, summary at +' + (at('summary') - t0) |
| 316 | + 'ms, slowest view at +' + (slowest - t0) + 'ms'); |
| 317 | |
| 318 | const land = await page.evaluate(() => |
| 319 | !!document.querySelector('#admin-map .admin-worldmap path.admin-land')); |
| 320 | check('world map renders its land outline', land); |
| 321 | const bubbles = await page.evaluate(() => |
| 322 | document.querySelectorAll('#admin-map circle.admin-bubble').length); |
| 323 | check('world map plots usage bubbles', bubbles > 0, bubbles + ' bubbles'); |
| 324 | |
| 325 | const revBars = await page.evaluate(() => |
| 326 | document.querySelectorAll('#admin-revenue .admin-bar').length); |
| 327 | check('revenue chart draws bars', revBars > 0, revBars + ' bars'); |
| 328 | |
| 329 | // The accounts and ledger views scan every account's ledger, so on a |
| 330 | // database several test runs deep they land well after the charts. |
| 331 | // Wait for them rather than sampling at a fixed moment. |
| 332 | await page.waitForFunction(() => |
| 333 | document.querySelectorAll('#admin-accounts table tbody tr').length > 0, |
| 334 | null, { timeout: 20000 }).catch(() => {}); |
| 335 | const acctRows = await page.evaluate(() => |
| 336 | document.querySelectorAll('#admin-accounts table tbody tr').length); |
| 337 | check('accounts table has rows', acctRows > 0, acctRows + ' rows'); |
| 338 | await page.waitForFunction(() => |
| 339 | document.querySelectorAll('#admin-ledger table tbody tr').length > 0, |
| 340 | null, { timeout: 20000 }).catch(() => {}); |
| 341 | const ledRows = await page.evaluate(() => |
| 342 | document.querySelectorAll('#admin-ledger table tbody tr').length); |
| 343 | check('ledger table has rows', ledRows > 0, ledRows + ' rows'); |
| 344 | |
| 345 | // ── Capacity: the growth picture ── |
| 346 | await page.waitForFunction(() => |
| 347 | document.querySelectorAll('#admin-cap-card .admin-meter').length > 0, |
| 348 | null, { timeout: 15000 }).catch(() => {}); |
| 349 | const caps = await page.evaluate(() => ({ |
| 350 | meters: document.querySelectorAll('#admin-cap-card .admin-meter').length, |
| 351 | storage: (document.getElementById('admin-cap-storage') || {}).textContent || '', |
| 352 | egress: (document.getElementById('admin-cap-egress') || {}).textContent || '', |
| 353 | })); |
| 354 | check('capacity draws a bar for storage and one for transfer', |
| 355 | caps.meters >= 2, caps.meters + ' meters'); |
| 356 | // It must say what happens AT the limit, not merely how full it is: |
| 357 | // the two limits behave differently and the difference is the point. |
| 358 | check('capacity says uploads are refused at the storage ceiling', |
| 359 | /refus|paus/i.test(caps.storage), caps.storage.slice(0, 90)); |
| 360 | check('capacity says transfer past the allowance is billed, not throttled', |
| 361 | /bill/i.test(caps.egress), caps.egress.slice(0, 90)); |
| 362 | |
| 363 | // ── Settings: the knobs, and one price actually moving ── |
| 364 | await page.waitForFunction(() => |
| 365 | document.querySelectorAll('#admin-set-card .admin-set-knob').length > 0, |
| 366 | null, { timeout: 15000 }).catch(() => {}); |
| 367 | const knobs = await page.evaluate(() => ({ |
| 368 | rows: document.querySelectorAll('#admin-set-card .admin-set-knob').length, |
| 369 | groups: document.querySelectorAll('#admin-set-card .admin-set-group').length, |
| 370 | editors: document.querySelectorAll('#admin-set-card .admin-set-edit').length, |
| 371 | })); |
| 372 | check('settings lists the knobs, grouped', knobs.rows > 10 && knobs.groups > 5, |
| 373 | knobs.rows + ' knobs in ' + knobs.groups + ' groups'); |
| 374 | check('an owner gets an editor on every knob', |
| 375 | knobs.editors === knobs.rows, knobs.editors + ' editors'); |
| 376 | |
| 377 | // Drive the real control: type a price, Save, confirm. |
| 378 | const priced = await page.evaluate(async () => { |
| 379 | const rows = Array.from(document.querySelectorAll('#admin-set-card .admin-set-knob')); |
| 380 | const row = rows.find(r => (r.querySelector('.admin-set-label') || {}).textContent |
| 381 | === 'Price per GiB-month'); |
| 382 | if (!row) return { found: false }; |
| 383 | const input = row.querySelector('.admin-set-input'); |
| 384 | // Must differ from the configured value (25): the console refuses to |
| 385 | // "save" a value equal to the current one, so an equal value would |
| 386 | // never set an override and the test would prove nothing. |
| 387 | input.value = '30'; |
| 388 | input.dispatchEvent(new Event('input', { bubbles: true })); |
| 389 | Array.from(row.querySelectorAll('button')) |
| 390 | .find(b => /save/i.test(b.textContent)).click(); |
| 391 | await new Promise(r => setTimeout(r, 200)); |
| 392 | // The confirm step is the guard: a price must not move on one click. |
| 393 | // The element always exists; the real check is that Save revealed it. |
| 394 | const q = row.querySelector('.admin-set-confirm'); |
| 395 | const asked = !!q && !q.hidden; |
| 396 | if (q) { |
| 397 | Array.from(q.querySelectorAll('button')) |
| 398 | .find(b => /confirm|yes/i.test(b.textContent)).click(); |
| 399 | } |
| 400 | await new Promise(r => setTimeout(r, 1200)); |
| 401 | const j = await (await fetch('/api/admin?view=settings', |
| 402 | { credentials: 'same-origin' })).json(); |
| 403 | const g = (j.groups || []).find(x => x.route === '/api/chunk') || {}; |
| 404 | const k = (g.knobs || []).find(x => x.key === 'storage_per_gib_month_minor') || {}; |
| 405 | return { found: true, asked, value: k.value, overridden: k.overridden, by: k.set_by }; |
| 406 | }); |
| 407 | check('changing a price asks for confirmation first', priced.found && priced.asked); |
| 408 | check('and the confirmed price reaches the gateway', |
| 409 | priced.value === '30' && priced.overridden === true, |
| 410 | 'value ' + priced.value + ', overridden ' + priced.overridden); |
| 411 | check('the change is attributed to the account that made it', |
| 412 | priced.by === owner, priced.by); |
| 413 | |
| 414 | // And it must be reversible, or an operator cannot safely try one. |
| 415 | const reset = await page.evaluate(async () => { |
| 416 | const r = await fetch('/api/admin?view=settings', { |
| 417 | method: 'POST', |
| 418 | credentials: 'same-origin', |
| 419 | headers: { 'content-type': 'application/json' }, |
| 420 | body: JSON.stringify({ route: '/api/chunk', |
| 421 | key: 'storage_per_gib_month_minor', value: '' }), |
| 422 | }); |
| 423 | const j = await r.json().catch(() => null); |
| 424 | return { status: r.status, knob: j && j.knob }; |
| 425 | }); |
| 426 | check('clearing an override puts the configured value back', |
| 427 | reset.status === 200 && reset.knob && reset.knob.value === '25' |
| 428 | && reset.knob.overridden === false, |
| 429 | 'status ' + reset.status + ', value ' + (reset.knob && reset.knob.value)); |
| 430 | |
| 431 | // The allowlist, exercised through the live surface rather than only |
| 432 | // in a unit test: an owner is the most privileged caller there is, |
| 433 | // and a secret must still be out of reach. |
| 434 | const forbidden = await page.evaluate(async () => { |
| 435 | const out = []; |
| 436 | for (const [route, key] of [ |
| 437 | ['/api/checkout/pro', 'stripe_key'], |
| 438 | ['/api/admin', 'owner_accounts'], |
| 439 | ]) { |
| 440 | const r = await fetch('/api/admin?view=settings', { |
| 441 | method: 'POST', |
| 442 | credentials: 'same-origin', |
| 443 | headers: { 'content-type': 'application/json' }, |
| 444 | body: JSON.stringify({ route, key, value: 'x' }), |
| 445 | }); |
| 446 | out.push(r.status); |
| 447 | } |
| 448 | return out; |
| 449 | }); |
| 450 | check('an owner still cannot set a key or the owner list from the console', |
| 451 | forbidden.every(s => s === 400), forbidden.join(', ')); |
| 452 | errs.length = 0; // those two 400s were asked for |
| 453 | |
| 454 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 455 | await page.screenshot({ path: path.join(SHOTS, 'admin-desktop.png'), fullPage: true }).catch(() => {}); |
| 456 | |
| 457 | // Mobile. |
| 458 | await page.setViewportSize({ width: 390, height: 844 }); |
| 459 | await sleep(500); |
| 460 | await page.screenshot({ path: path.join(SHOTS, 'admin-mobile.png'), fullPage: true }).catch(() => {}); |
| 461 | |
| 462 | check('no console errors on the dashboard', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 463 | } catch (e) { |
| 464 | // What the PAGE said, not only what the wait gave up on: the |
| 465 | // exception that stopped the console filling itself is in `errs`, |
| 466 | // and without this line it goes out with the browser. |
| 467 | check('browser run completed without throwing', false, |
| 468 | e.message + (errs.length ? ' | page: ' + errs.slice(0, 5).join(' | ') : ' | page reported nothing')); |
| 469 | } finally { |
| 470 | await browser.close(); |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | // The reason lives in the gateway's log and nowhere else, so a failing run |
| 475 | // prints it rather than leaving it on disk for somebody to go and find -- |
| 476 | // and before `cleanup`, which SIGKILLs the process being asked. |
| 477 | if (bad.length) saidWhat(); |
| 478 | cleanup(); |
| 479 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 480 | process.exit(bad.length ? 1 : 0); |
| 481 | })().catch(e => { console.error(e); saidWhat(); cleanup(); process.exit(1); }); |