197 lines
8.9 KiB
TypeScript
197 lines
8.9 KiB
TypeScript
/**
|
|
* release-broadcast-smoke (cp317)
|
|
*
|
|
* Guards the release-op broadcast tooling: the pure op-builder +
|
|
* view-key guard, and the CLI's safety invariants (dry-run asks for
|
|
* no key, the key is read masked and never persisted, laptop-only
|
|
* banner present).
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
buildReleaseCustomJsonOp,
|
|
assertNoSecretHex,
|
|
RELEASE_OP_ID,
|
|
RELEASE_SIGNER_DEFAULT,
|
|
BLURT_CUSTOM_JSON_MAX_BYTES
|
|
} from '../src/blurt/releaseBroadcastOp.ts';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const REPO = join(HERE, '..', '..', '..');
|
|
|
|
let failures = 0;
|
|
let scenarios = 0;
|
|
const ok = (m: string) => {
|
|
console.log(` ✓ ${m}`);
|
|
scenarios++;
|
|
};
|
|
const bad = (m: string, d: string) => {
|
|
console.error(` ✗ ${m}\n ${d}`);
|
|
failures++;
|
|
scenarios++;
|
|
};
|
|
const throws = (label: string, fn: () => unknown, needle: string) => {
|
|
try {
|
|
fn();
|
|
bad(`${label} should throw`, 'did not throw');
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
if (msg.includes(needle)) ok(`${label} → throws (${needle})`);
|
|
else bad(`${label} threw wrong error`, msg);
|
|
}
|
|
};
|
|
|
|
const BTC = 'bc1qdwaelg52ts3e0m8fellkw5u9x7plfwc0kxnwnk';
|
|
const XMR = '84bwu2PWp3NaRudAKTadmeZPBLTjL5f4bKU8F6NJKqxgUvwth6QxUVSUNFAQnHbbuQcMRNR4baYUKNcZXQtKMMKm4aVE3Fe';
|
|
const VALID = JSON.stringify({
|
|
version: '1.0.0',
|
|
hash_manifest: { 'app.js': 'sha256-' + 'A'.repeat(43) + '=' },
|
|
endpoints: { blurt_rpc: ['https://rpc.beblurt.com'] },
|
|
treasury: { btc: { address: BTC, satoshis: 416 }, xmr: { address: XMR, piconero: '781250000' } }
|
|
});
|
|
|
|
// ── 1. valid payload → correct op shape ───────────────────────────
|
|
const op = buildReleaseCustomJsonOp(VALID);
|
|
if (
|
|
op.id === RELEASE_OP_ID &&
|
|
op.required_auths.length === 0 &&
|
|
op.required_posting_auths.length === 1 &&
|
|
op.required_posting_auths[0] === RELEASE_SIGNER_DEFAULT &&
|
|
op.json === VALID.trim()
|
|
)
|
|
ok('valid payload → op {id:morphit_release_v1, posting_auths:[morphit], json=input}');
|
|
else bad('valid payload produced wrong op', JSON.stringify(op));
|
|
|
|
// ── 2. custom signer respected ────────────────────────────────────
|
|
if (buildReleaseCustomJsonOp(VALID, 'example-op').required_posting_auths[0] === 'example-op')
|
|
ok('custom --signer is honored');
|
|
else bad('custom signer not honored', '');
|
|
|
|
// ── cp560 — a distribution block's source_sha256 is LEGITIMATELY 64-hex
|
|
// and must NOT trip the secret-hex guard. Before the fix, the
|
|
// broadcaster scanned the whole payload and REFUSED every release that
|
|
// carried a distribution anchor (caught live during the v1.8.15
|
|
// ceremony). The guard now excludes the strictly-validated distribution
|
|
// block, mirroring the builder. ──
|
|
const WITH_DISTRIBUTION = JSON.stringify({
|
|
version: '1.8.15',
|
|
hash_manifest: { 'app.js': 'sha256-' + 'A'.repeat(43) + '=' },
|
|
treasury: { btc: { address: BTC, satoshis: 416 }, xmr: { address: XMR, piconero: '781250000' } },
|
|
distribution: {
|
|
source_sha256: 'a'.repeat(64),
|
|
gpg_fingerprint: '7B4C1D189DBB610C473B59ED53524E1F1017EB9C',
|
|
mirrors: ['https://codeberg.org/agorise/morphit', 'https://github.com/agorise/morphit']
|
|
}
|
|
});
|
|
try {
|
|
const opDist = buildReleaseCustomJsonOp(WITH_DISTRIBUTION);
|
|
if (opDist.id === RELEASE_OP_ID && opDist.json === WITH_DISTRIBUTION.trim())
|
|
ok('cp560 — distribution source_sha256 (64-hex) does NOT trip the secret guard');
|
|
else bad('cp560 — distribution payload produced wrong op', JSON.stringify(opDist));
|
|
} catch (e) {
|
|
bad('cp560 — distribution block wrongly rejected by the secret guard', e instanceof Error ? e.message : String(e));
|
|
}
|
|
// …but a 64-hex OUTSIDE the distribution block is STILL caught — the strip
|
|
// is surgical, the treasury/other blocks stay scanned.
|
|
throws(
|
|
'cp560 — a 64-hex outside distribution is still refused',
|
|
() => assertNoSecretHex(JSON.stringify({ treasury: { note: 'f'.repeat(64) } })),
|
|
'secret key'
|
|
);
|
|
|
|
|
|
const NO_ENDPOINTS = JSON.stringify({
|
|
version: '1.1.0',
|
|
hash_manifest: { 'app.js': 'sha256-' + 'A'.repeat(43) + '=' },
|
|
treasury: { btc: { address: BTC, satoshis: 416 }, xmr: { address: XMR, piconero: '781250000' } }
|
|
});
|
|
const opNoEp = buildReleaseCustomJsonOp(NO_ENDPOINTS);
|
|
if (opNoEp.id === RELEASE_OP_ID && opNoEp.json === NO_ENDPOINTS.trim() && !opNoEp.json.includes('endpoints'))
|
|
ok('cp436 — no-endpoints payload → valid op, no endpoints pinned');
|
|
else bad('cp436 — no-endpoints payload failed', JSON.stringify(opNoEp));
|
|
|
|
// ── 3. invalid payload (bad version) → validation error ───────────
|
|
throws(
|
|
'invalid payload (version not semver)',
|
|
() => buildReleaseCustomJsonOp(JSON.stringify({ ...JSON.parse(VALID), version: 'nope' })),
|
|
'failed validation'
|
|
);
|
|
|
|
// ── 4. non-JSON → JSON error ──────────────────────────────────────
|
|
throws('non-JSON payload', () => buildReleaseCustomJsonOp('{ not json'), 'not valid JSON');
|
|
|
|
// ── 5. 64-hex (view key) → refused ────────────────────────────────
|
|
throws(
|
|
'assertNoSecretHex on a 64-hex string',
|
|
() => assertNoSecretHex('prefix ' + 'a'.repeat(64) + ' suffix'),
|
|
'secret key'
|
|
);
|
|
|
|
// ── 5b. cp430 — a hash_manifest over the indexer's 4096-byte per-field
|
|
// JSONB cap is now rejected up front by validateReleasePayload
|
|
// (schema cap lowered from 64 KB to match the handler). This is
|
|
// the EXACT failure that reached the chain on 1.0.0 and got
|
|
// filed valid=false → /v1/release not_found. ──
|
|
const bigManifest: Record<string, string> = {};
|
|
for (let i = 0; i < 120; i++) {
|
|
bigManifest[`/_app/immutable/nodes/${i}.CFakeHash00000${i}.js`] = 'sha256-' + 'A'.repeat(43) + '=';
|
|
}
|
|
const manifestBytes = new TextEncoder().encode(JSON.stringify(bigManifest)).length;
|
|
if (manifestBytes > 4096) ok(`oversized manifest fixture is ${manifestBytes} bytes — over the 4096 per-field cap`);
|
|
else bad('oversized manifest fixture is not over 4096', String(manifestBytes));
|
|
throws(
|
|
'manifest over the 4096 per-field cap → rejected before broadcast',
|
|
() => buildReleaseCustomJsonOp(JSON.stringify({ ...JSON.parse(VALID), hash_manifest: bigManifest })),
|
|
'hash_manifest_too_large'
|
|
);
|
|
// a normal (small) payload stays under the whole-op chain limit and builds
|
|
const okSized = buildReleaseCustomJsonOp(VALID);
|
|
if (new TextEncoder().encode(okSized.json).length < BLURT_CUSTOM_JSON_MAX_BYTES)
|
|
ok('a normal (small) payload stays under the 8192-byte chain limit and builds');
|
|
else bad('normal payload unexpectedly over the limit', '');
|
|
|
|
// ── 6. real payload (SRI base64) passes the hex guard ─────────────
|
|
try {
|
|
assertNoSecretHex(VALID);
|
|
ok('real payload (SRI base64) passes the secret-hex guard (no false positive)');
|
|
} catch (e) {
|
|
bad('real payload wrongly flagged as secret hex', e instanceof Error ? e.message : String(e));
|
|
}
|
|
|
|
// ── 7. invalid signer name → refused ──────────────────────────────
|
|
throws('invalid signer name', () => buildReleaseCustomJsonOp(VALID, 'BadName!!'), 'invalid signer');
|
|
|
|
// ── 8-11. CLI safety static guards ────────────────────────────────
|
|
const cli = readFileSync(join(REPO, 'apps/indexer/scripts/release-broadcast.ts'), 'utf-8');
|
|
if (cli.includes("from '../src/blurt/releaseBroadcastOp.ts'"))
|
|
ok('CLI builds the op via the pure, tested module');
|
|
else bad('CLI no longer uses the pure op-builder module', 'validation/guard could drift');
|
|
|
|
// dry-run must exit BEFORE the key prompt (askHidden).
|
|
const dryIdx = cli.indexOf('if (dryRun)');
|
|
const askIdx = cli.indexOf('askHidden(');
|
|
if (dryIdx !== -1 && askIdx !== -1 && dryIdx < askIdx)
|
|
ok('--dry-run exits before any key is requested');
|
|
else bad('--dry-run no longer precedes the key prompt', 'dry-run could leak into the key path');
|
|
|
|
if (cli.includes('_writeToOutput') && /askHidden/.test(cli))
|
|
ok('posting key is read via a masked prompt (echo suppressed)');
|
|
else bad('posting key prompt is no longer masked', 'WIF could echo to the screen');
|
|
|
|
if (!/writeFileSync\([^)]*wif/i.test(cli) && !/console\.log\([^)]*wif/i.test(cli) && !/process\.env\.[A-Z_]*WIF/.test(cli))
|
|
ok('posting key is never written to disk, logged, or read from an env var');
|
|
else bad('posting key may be persisted/logged/env-sourced', 'key-handling regression');
|
|
|
|
if (cli.includes('LAPTOP ONLY')) ok('CLI carries the LAPTOP-ONLY warning banner');
|
|
else bad('LAPTOP-ONLY banner removed', 'operator might run it on the server with the posting key');
|
|
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|