prometheus/scripts/test-listing-roundtrip.mjs
Local Dev 916440fb31 prometheus: /submit becomes a 4-step publisher wizard
The submit page was a one-form upload tester that said "not wired yet"
on the three steps that would turn an upload into a published listing.
It now walks a publisher through the whole thing in-page.

Four client-side steps, state in one object (W = {file, upload, manifest,
message, signature}), scroll-to-top on each transition, back buttons
preserved so revisions are a correction not a restart:

  Step 1 — Upload
    File picker → POST /api/upload. Response rendered as a card showing
    name + size + short hash + the scan verdict chip ("scan ✓ clean
    (clamav, 2ms)"). Advance button only enabled when scan is clean;
    infected or error paths show the reason inline and stay on step 1.

  Step 2 — Describe
    Form fields for publisher BNS name, slug, display name, category
    (optgroup'd by top-level: Apps subs, then Games, then FinTech),
    version, description, download URL, homepage + icon (optional).
    Pattern + maxlength validation matches the server's rules in
    service/listings.js. Submit builds W.manifest and canonicalises
    the signing message.

  Step 3 — Sign
    Shows the canonical message to sign. If window.bitcoincash is
    injected (Theseus with Aegis), a one-click "Sign with Theseus
    wallet" button calls bitcoincash.signMessage(). Fallback paste
    textarea accepts a 86–88 char BIP-137 signature from any wallet
    (Electron Cash, Deviant, Cashonize). Both paths populate
    W.signature and enable the continue button.

  Step 4 — Publish
    POST /api/listing with the full body. On 201 shows the listing
    card + a "View your listing →" button pointing at
    /app/<pub>/<slug>. On error shows the server's reason inline and
    re-enables the Publish button for retry.

Canonical JSON in the client is a direct copy of service/listings.js's
canonicalJson — same bytes, same signature. A test that drifts them
apart would be nice; for now a comment on each copy flags the pairing.

scripts/test-listing-roundtrip.mjs's default category changed from the
old `utilities` slug (removed in the taxonomy change) to `productivity`.
2026-10-22 22:58:35 +03:00

150 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* End-to-end roundtrip for the listing endpoint.
*
* Not a CI test — a manual sanity check you can run against a locally running
* service to confirm every link in the chain is wired. It:
*
* 1. generates a test key, derives the cashaddress,
* 2. uploads a small file to /api/upload (requires the server with
* MOCK_SCAN=clean),
* 3. builds a listing body around the scan report, signs the canonical
* message with the test key (BIP-137),
* 4. POSTs it to /api/listing (requires MOCK_BNS_OWNER set to the test
* address so the on-chain-owner check passes without a real chipnet
* lookup), and
* 5. reads /api/listings and prints the row.
*
* Run the service like this:
* MOCK_SCAN=clean MOCK_BNS_OWNER=<the address this script prints> \
* node service/server.js
*
* Then from another terminal:
* node scripts/test-listing-roundtrip.mjs
*/
import { signMessageBip137, addressFromPrivateKey } from '../service/lib/sign-message.js';
const BASE = process.env.BASE || 'http://127.0.0.1:3700';
const PUBLISHER = process.env.PUBLISHER || 'testpub.x';
const SLUG = process.env.SLUG || `hello-world-${Date.now()}`;
const CATEGORY = process.env.CATEGORY || 'productivity';
// A hard-coded deterministic test key — this is NEVER a real wallet key, just
// a predictable 32-byte scalar so the roundtrip script can print the matching
// cashaddress BEFORE anyone sets MOCK_BNS_OWNER, instead of asking you to
// chase your own tail. Override with TEST_PRIVKEY=<64 hex> if a different key
// is useful (e.g. testing two publishers at once).
const DEFAULT_TEST_PRIVKEY_HEX =
'0101010101010101010101010101010101010101010101010101010101010101';
const PRIVKEY_HEX = process.env.TEST_PRIVKEY || DEFAULT_TEST_PRIVKEY_HEX;
if (!/^[0-9a-f]{64}$/i.test(PRIVKEY_HEX)) {
console.error('TEST_PRIVKEY must be 64 hex chars');
process.exit(2);
}
const privateKey = Buffer.from(PRIVKEY_HEX, 'hex');
const log = (...a) => console.log('[test]', ...a);
const fail = (msg, extra) => {
console.error('[test] FAIL:', msg);
if (extra !== undefined) console.error(extra);
process.exit(1);
};
async function main() {
// 1. derive the test address from the deterministic key
const address = addressFromPrivateKey(privateKey);
log(`test address: ${address}`);
log(`→ server must have: MOCK_SCAN=clean MOCK_BNS_OWNER=${address}`);
// 2. upload
log('uploading a 20-byte sample file …');
const sample = Buffer.from(`prometheus listing test ${Date.now()}`);
const form = new FormData();
form.append('file', new Blob([sample], { type: 'text/plain' }), 'sample.txt');
const upResp = await fetch(`${BASE}/api/upload`, { method: 'POST', body: form });
const upJson = await upResp.json();
if (!upResp.ok || !upJson.ok) fail('upload failed', upJson);
log(`upload hash: ${upJson.upload.hash} size: ${upJson.upload.size} verdict: ${upJson.scan.verdict}`);
// 3. build the listing
const listing = {
publisher: PUBLISHER,
slug: SLUG,
name: 'Hello, world',
description: 'An unremarkable sample listing to prove the publisher-sign path works end-to-end.',
category: CATEGORY,
version: '0.1.0',
homepage: 'https://example.com',
icon: 'https://example.com/icon.png',
download: {
url: 'https://example.com/sample.txt',
sha256: upJson.upload.hash,
size: upJson.upload.size,
},
scan_report: upJson.report,
};
// 4. canonical signing message, same shape the service builds
const message = buildSigningMessage(listing);
const signature = signMessageBip137(message, privateKey);
log(`signed ${message.length} bytes of canonical message`);
const body = { ...listing, signature };
// 5. POST /api/listing
log(`POST ${BASE}/api/listing …`);
const resp = await fetch(`${BASE}/api/listing`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const json = await resp.json();
if (!resp.ok || !json.ok) fail(`POST returned ${resp.status}`, json);
log(`✓ listing created: ${json.id}`);
// 6. read it back via /api/listings/<id> and /api/listings?category=…
const detail = await (await fetch(`${BASE}/api/listings/${json.id}`)).json();
if (!detail.ok) fail('GET detail failed', detail);
log(`✓ GET /api/listings/${json.id} returned ${detail.listing.name} v${detail.listing.version}`);
const index = await (await fetch(`${BASE}/api/listings?category=${CATEGORY}`)).json();
if (!index.ok) fail('GET index failed', index);
const found = index.listings.find((l) => l.id === json.id);
if (!found) fail(`listing ${json.id} missing from category index`, index);
log(`✓ listing appears in /api/listings?category=${CATEGORY} (${index.count} total in category)`);
log('all four checks passed.');
}
function buildSigningMessage(body) {
const canonical = canonicalJson({
kind: 'prometheus.listing',
v: 1,
publisher: String(body.publisher).toLowerCase(),
slug: String(body.slug).toLowerCase(),
name: String(body.name),
description: String(body.description),
category: String(body.category),
version: String(body.version),
homepage: body.homepage || null,
icon: body.icon || null,
download: {
url: String(body.download.url),
sha256: String(body.download.sha256),
size: Number(body.download.size),
},
scan_report_pubkey: body.scan_report.pubkey,
scan_report_signature: body.scan_report.signature,
});
return `PROMETHEUS-LISTING\n${canonical}`;
}
function canonicalJson(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
const keys = Object.keys(value).sort();
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
}
main().catch((err) => fail(err?.message || String(err), err?.stack));