Oregami
Repositories/oxedyne/daimond

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
15import fs from 'node:fs';
16import os from 'node:os';
17import path from 'node:path';
18import crypto from 'node:crypto';
19import { spawn } from 'node:child_process';
20import { fileURLToPath, pathToFileURL } from 'node:url';
21import { signInFresh } from './session.mjs';
22import { requireFreshGateway, procLog, GWCWD } from './gwbin.mjs';
23import { GW_PORT, GW_URL } from './ports.mjs';
24
25const HERE = path.dirname(fileURLToPath(import.meta.url));
26const ROOT = path.join(HERE, '..');
27const GWDIR = path.join(ROOT, 'gateway');
28const 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.
32const GW_LOG = procLog('verify_admin');
33/// And the dev server, which fails the same silent way when its port is taken.
34const 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.
37const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`;
38
39const PW = process.env.DAIMOND_PW
40 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
41const CHROME = process.env.DAIMOND_CHROME
42 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
43
44const ok = [], bad = [];
45/// Every `/api/admin` response the console received, as `{view, status}`.
46const adminResp = [];
47const check = (name, pass, detail) => {
48 (pass ? ok : bad).push(name);
49 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
50};
51const sleep = ms => new Promise(r => setTimeout(r, ms));
52
53const WHSEC = fs.readFileSync(path.join(GWDIR, 'keys/stripe/sandbox/whsec'), 'utf8').trim();
54
55const procs = [];
56function launch(cmd, args, opts) {
57 const p = spawn(cmd, args, opts);
58 procs.push(p);
59 return p;
60}
61async 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}
69function 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.
73function 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.
78async 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.
98async 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
119async 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.
140async 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); });