prometheus/scripts/test-admin-roundtrip.mjs

123 lines
5.3 KiB
JavaScript
Raw Normal View History

prometheus: admin panel — login, listings, 3-mode toggle, audit log /admin is wallet-gated. The configured ADMIN_PUBLISHER is a BNS name; only the on-chain owner of that name can sign in. The sign-in flow is BIP-137 (same primitive publishers use): the login page shows a fresh one-shot challenge, the admin signs it in their wallet, pastes the signature, and the service verifies that it recovers to the current on-chain owner. Session is a 24-hour ed25519-signed cookie minted by the service key. Pages: /admin dashboard — totals, mode state, recent listings + audit /admin/login BIP-137 challenge sign-in /admin/listings all listings, with a Withdraw button per row /admin/modes global + per-category publish-mode toggle /admin/audit the hash-chained audit log The three publish modes exist as toggles: `append` — any valid listing goes in (launch default) `scan-first` — infected uploads are rejected before they land `review` — clean listings wait for human approval Global mode is the fallback; a per-category override takes precedence when set. The toggle persists; the review-queue enforcement lands in a later milestone (the enforcement sketch is in modes.js's header). Audit log: Every admin action (login, set_mode, clear_mode, withdraw_listing) is an append-only row with a sha256 chain over the previous row's hash. The chain does not prevent edits; it makes retroactive ones loud — row N's `prev_hash` disagreeing with row N-1's `this_hash` is the tamper signal. Withdrawal: Soft — `withdrawn_at` on the listing row. Browse hides withdrawn listings (already the case) and the detail page returns 404 for them. Nothing in the chain is undone; this is Prometheus's own view only. Session design: Cookie is `prom_admin=<payload b64url>.<signature b64url>`, signed with the ed25519 service key, HttpOnly, SameSite=Strict, Path=/admin, 24-hour expiry. Reconfiguring ADMIN_PUBLISHER invalidates in-flight sessions by name mismatch. Challenges live in a 10-min TTL map and are tied to a specific name so one admin's challenge cannot authenticate another. Added: service/admin.js route handlers + dispatch service/admin-pages.js HTML templates for the four pages service/admin-session.js challenge + cookie mint/verify + readSession service/audit.js hash-chained append helper service/modes.js publish-mode read/write with override semantics schema: settings + audit tables scripts/test-admin-roundtrip.mjs drives the whole flow (unauth redirect → challenge scrape → sign → login → dashboard → set mode → audit inspection) with the same deterministic test key the listing-roundtrip script uses. Withdraw-flow spot-check: create a listing, log in as admin, withdraw it, confirm browse hides it and the detail page returns 404. All seven checks pass.
2026-10-08 21:35:48 +02:00
#!/usr/bin/env node
/**
* End-to-end admin flow against a locally running service.
*
* 1. GET /admin → expect 302 to /admin/login (no session yet).
* 2. GET /admin/login → scrape the challenge out of the HTML.
* 3. Sign the challenge with the deterministic test key.
* 4. POST /admin/login → expect 302 + set-cookie.
* 5. GET /admin with the cookie → expect 200 and "Dashboard" in the body.
* 6. POST /admin/modes → change global to scan-first; verify it stuck.
* 7. GET /admin/audit → expect at least one row (the login + the mode set).
*
* Server must be started with:
* MOCK_SCAN=clean \
* MOCK_BNS_OWNER=bchtest:qpumqqygwcnt999fz3gp5nxjy66ckg6esvmzshj478 \
* ADMIN_PUBLISHER=admin.x \
* node service/server.js
*
* (MOCK_BNS_OWNER applies to every lookup, so both the admin's name and any
* publisher name resolve to the same deterministic test address.)
*/
import { signMessageBip137 } from '../service/lib/sign-message.js';
const BASE = process.env.BASE || 'http://127.0.0.1:3700';
const PRIVKEY_HEX = process.env.TEST_PRIVKEY
|| '0101010101010101010101010101010101010101010101010101010101010101';
const privateKey = Buffer.from(PRIVKEY_HEX, 'hex');
const log = (...a) => console.log('[admin-test]', ...a);
const fail = (msg, extra) => {
console.error('[admin-test] FAIL:', msg);
if (extra !== undefined) console.error(extra);
process.exit(1);
};
async function main() {
// 1. GET /admin — should redirect.
const r1 = await fetch(`${BASE}/admin`, { redirect: 'manual' });
if (r1.status !== 302 || !r1.headers.get('location')?.includes('/admin/login')) {
fail(`GET /admin without session: expected 302 to login, got ${r1.status}`,
r1.headers.get('location'));
}
log(`✓ GET /admin without session → 302 ${r1.headers.get('location')}`);
// 2. GET /admin/login — scrape challenge.
const r2 = await fetch(`${BASE}/admin/login`);
if (r2.status !== 200) fail(`GET /admin/login: expected 200, got ${r2.status}`);
const html2 = await r2.text();
const m = html2.match(/challenge" value="([A-Za-z0-9_\-=]+)"/);
if (!m) fail('could not find challenge in login page', html2.slice(0, 400));
const challenge = m[1];
const nameMatch = html2.match(/name=([a-z0-9.\-]+)\n/);
const adminName = nameMatch ? nameMatch[1] : 'admin.x';
log(`✓ GET /admin/login → challenge ${challenge.slice(0, 16)}… for ${adminName}`);
// 3. Sign.
const message = `PROMETHEUS-ADMIN-LOGIN\nname=${adminName}\nchallenge=${challenge}`;
const signature = signMessageBip137(message, privateKey);
log(`✓ signed challenge (${signature.length} base64 chars)`);
// 4. POST /admin/login.
const r4 = await fetch(`${BASE}/admin/login`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
redirect: 'manual',
body: new URLSearchParams({ challenge, signature }).toString(),
});
if (r4.status !== 302) {
fail(`POST /admin/login: expected 302, got ${r4.status}`, await r4.text());
}
const cookie = r4.headers.get('set-cookie');
if (!cookie || !cookie.startsWith('prom_admin=')) {
fail('login did not set prom_admin cookie', cookie);
}
const token = cookie.split(';')[0]; // "prom_admin=…"
log(`✓ POST /admin/login → 302 + ${token.slice(0, 24)}…`);
const headers = { cookie: token };
// 5. GET /admin with cookie.
const r5 = await fetch(`${BASE}/admin`, { headers });
if (r5.status !== 200) fail(`GET /admin with session: expected 200, got ${r5.status}`);
const html5 = await r5.text();
if (!/Dashboard/.test(html5)) fail('dashboard HTML missing "Dashboard"', html5.slice(0, 400));
log(`✓ GET /admin with session → 200 (dashboard)`);
// 6. POST /admin/modes — flip global to scan-first.
const modesBody = new URLSearchParams();
modesBody.append('global', 'scan-first');
for (const slug of ['communication', 'privacy', 'wallets', 'games', 'devtools', 'media', 'utilities', 'other']) {
modesBody.append(`cat_${slug}`, '');
}
const r6 = await fetch(`${BASE}/admin/modes`, {
method: 'POST',
headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' },
redirect: 'manual',
body: modesBody.toString(),
});
if (r6.status !== 302) fail(`POST /admin/modes: expected 302, got ${r6.status}`);
const r6r = await fetch(`${BASE}/admin/modes`, { headers });
const html6 = await r6r.text();
if (!/<option value="scan-first" selected>scan-first<\/option>[\s\S]*?Global/.test(html6)
&& !/Global[\s\S]*?<option value="scan-first" selected>scan-first<\/option>/.test(html6)) {
// Simpler check: just verify the string appears for the global row
if (!html6.includes(`<option value="scan-first" selected>scan-first</option>`)) {
fail('mode toggle did not persist (no selected scan-first in HTML)');
}
}
log(`✓ POST /admin/modes global=scan-first → persisted`);
// 7. GET /admin/audit — expect rows.
const r7 = await fetch(`${BASE}/admin/audit`, { headers });
const html7 = await r7.text();
if (!/set_mode/.test(html7)) fail('audit log missing set_mode row');
if (!/login/.test(html7)) fail('audit log missing login row');
log(`✓ GET /admin/audit → login + set_mode rows present`);
log('all admin-flow checks passed.');
}
main().catch((err) => fail(err?.message || String(err), err?.stack));