Added opt-in canonical observer polling lifecycle
This commit is contained in:
@@ -28,6 +28,7 @@ import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellati
|
||||
import { replayReservedSale, type SaleRehearsal } from '../../server/market/sale-rehearsal.ts';
|
||||
import { keccak } from 'hash-wasm';
|
||||
import type { CanonicalInboxPage } from '../../server/market/canonical-inbox.ts';
|
||||
import { CanonicalObserver } from '../../server/market/canonical-observer.ts';
|
||||
import { Ledger } from '../../server/testnet/ledger.ts';
|
||||
import { GENESIS_TIME, makeTemplate, miningBlob, mintId, transition } from '../../server/testnet/consensus.ts';
|
||||
import { deriveStats } from '../../shared/enrichment.ts';
|
||||
@@ -106,6 +107,9 @@ class FixturePow {
|
||||
async hash(key: string, blob: Uint8Array): Promise<string> { return sha256(Buffer.concat([Buffer.from(key, 'hex'), Buffer.from(blob)])); }
|
||||
close(): void {}
|
||||
}
|
||||
class EasyFixturePow extends FixturePow {
|
||||
async hash(_key: string, _blob: Uint8Array): Promise<string> { return '0'.repeat(64); }
|
||||
}
|
||||
function fixtureArticle(pageId = 50000) {
|
||||
return { pageId, title: 'Canonical inbox fixture', extract: `Synthetic local-only canonical observer test fixture ${pageId}.`,
|
||||
image: 'https://upload.wikimedia.org/wikipedia/commons/test-image.jpg', url: `https://en.wikipedia.org/?curid=${pageId}`,
|
||||
@@ -117,13 +121,13 @@ function fixtureStats() {
|
||||
languagesTruncated: false, pageviews: { status: 'unavailable' as const, months: [], latestMonth: '2026-09',
|
||||
total: null, dailyAverage: null, trendPercent: null }, stats: deriveStats(pageBytes, languageCount, null) };
|
||||
}
|
||||
async function fixtureBlock(ancestors: TestnetBlock[], state: NftStateView, pageId = 50000) {
|
||||
async function fixtureBlock(ancestors: TestnetBlock[], state: NftStateView, pageId = 50000, proof = new FixturePow()) {
|
||||
const miner = seller;
|
||||
const template = makeTemplate(ancestors, state, miner.actor, miner.publicKey, [], fixtureArticle(pageId), fixtureStats(),
|
||||
GENESIS_TIME + (ancestors.length + 1) * 30);
|
||||
const header = structuredClone(template.header), pow = new FixturePow();
|
||||
const header = structuredClone(template.header);
|
||||
for (header.nonce = 0; header.nonce < 100000; header.nonce++) {
|
||||
const blob = miningBlob(header), powHash = await pow.hash(header.key, blob);
|
||||
const blob = miningBlob(header), powHash = await proof.hash(header.key, blob);
|
||||
if (BigInt(`0x${powHash}`) > BigInt(`0x${header.target}`)) continue;
|
||||
const hash = sha256(blob);
|
||||
return { ...header, hash, tokenId: mintId(header), powHash,
|
||||
@@ -276,9 +280,10 @@ test('SQLite cutover preserves identity, history, prepared attempts, ordering an
|
||||
test('PostgreSQL HTTP custody integration, safe storage binding and backup refusal use the real runtime role', { timeout: 45000 }, async t => {
|
||||
const f = await fixture(t), dir = await mkdtemp(join(tmpdir(), 'custody-pg-http-'));
|
||||
const urlFile = join(dir, 'pg-url'); await writeFile(urlFile, 'postgresql://custody_runtime@127.0.0.1/custody_test', { mode: 0o600 });
|
||||
const env = { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production', WIKIDECK_DATA_DIR: dir,
|
||||
const env = { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'test', WIKIDECK_DATA_DIR: dir,
|
||||
WIKIDECK_CUSTODY_MODE: 'sandbox', WIKIDECK_CUSTODY_STORAGE: 'postgres', WIKIDECK_CUSTODY_PG_URL_FILE: urlFile,
|
||||
WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1', WIKIDECK_PAYMENT_MODE: 'mock', WIKIDECK_PEERS: '' };
|
||||
WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1', WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1',
|
||||
WIKIDECK_PAYMENT_MODE: 'mock', WIKIDECK_PEERS: '' };
|
||||
const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
t.after(async () => { if (child.exitCode === null) child.kill('SIGTERM'); await closed; await rm(dir, { recursive: true, force: true }); });
|
||||
@@ -289,12 +294,14 @@ test('PostgreSQL HTTP custody integration, safe storage binding and backup refus
|
||||
child.once('exit', () => { clearTimeout(timer); reject(new Error('HTTP startup failed')); });
|
||||
});
|
||||
const page = await (await fetch(`${base}/api/custody`)).json(); assert.equal(page.info.custodianId, f.market.id);
|
||||
const observerStream = await f.admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='canonical-inbox:v1:stream'");
|
||||
assert.match(observerStream.rows[0].value, /^[a-f0-9-]{36}$/);
|
||||
const command = envelope(f.market.id, buyer, { type: 'mock-credit' });
|
||||
const response = await fetch(`${base}/api/custody/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command) });
|
||||
assert.equal(response.status, 200); assert.equal((await response.json()).availableAtomicXmr, '1000000000000');
|
||||
assert.match(response.headers.get('cache-control') ?? '', /no-store/);
|
||||
assert.equal(JSON.parse(await readFile(join(dir, 'testnet', 'custody-postgres.json'), 'utf8')).custodianId, f.market.id);
|
||||
child.kill('SIGTERM'); await closed;
|
||||
child.kill('SIGTERM'); await closed; assert.equal(child.exitCode, 0, 'opt-in observer should drain before custody shutdown');
|
||||
await assert.rejects(createBackup(dir, join(dir, '..', `refused-${randomUUID()}`)), /coordinated/);
|
||||
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'sqlite' }), /fallback prohibited/);
|
||||
});
|
||||
@@ -303,6 +310,14 @@ test('configuration requires secret files, verified TLS, literal backend, and lo
|
||||
const dir = await mkdtemp(join(tmpdir(), 'custody-pg-config-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'connection');
|
||||
assert.equal(custodyStorage({}), 'sqlite'); assert.throws(() => custodyStorage({ WIKIDECK_CUSTODY_STORAGE: 'other' }));
|
||||
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'sqlite',
|
||||
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /requires PostgreSQL/);
|
||||
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'postgres',
|
||||
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1' }), /disposable database test guard/);
|
||||
await assert.rejects(openCustodyStorage(dir, options, { NODE_ENV: 'production', WIKIDECK_CUSTODY_STORAGE: 'postgres',
|
||||
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /requires PostgreSQL/);
|
||||
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'postgres',
|
||||
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: 'yes', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /exactly 0 or 1/);
|
||||
await assert.rejects(postgresConfig({}), /configuration/);
|
||||
await writeFile(file, 'postgresql://user:secret@database.example/custody');
|
||||
const secure = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }); assert.equal((secure.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true);
|
||||
@@ -562,6 +577,58 @@ test('gated adapter consumes actual validated local Ledger connect and reorg pag
|
||||
await observer.audit();
|
||||
});
|
||||
|
||||
test('scheduled observer bounds catch-up, stops cleanly, and resumes its durable cursor after restart', async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-pg-observer-runner-'));
|
||||
const pow = new EasyFixturePow(), ledger = await Ledger.open(dir, pow), f = await fixture(t);
|
||||
const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
|
||||
t.after(async () => { await observer.close(); ledger.close(); pow.close(); await rm(dir, { recursive: true, force: true }); });
|
||||
const chain: TestnetBlock[] = []; let state: NftStateView = {};
|
||||
for (let i = 0; i < 66; i++) {
|
||||
const block = await fixtureBlock(chain, state, 51000 + i, pow);
|
||||
await ledger.submit(block); state = transition(state, block, []); chain.push(block);
|
||||
}
|
||||
const firstRunner = new CanonicalObserver(observer, ledger, { intervalMs: 60000, pagesPerCycle: 1 });
|
||||
try {
|
||||
const priorMode = process.env.NODE_ENV, priorGate = process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
|
||||
try {
|
||||
process.env.NODE_ENV = 'production';
|
||||
assert.throws(() => new CanonicalObserver(observer, ledger), /non-production/);
|
||||
process.env.NODE_ENV = 'test'; delete process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
|
||||
assert.throws(() => new CanonicalObserver(observer, ledger), /non-production/);
|
||||
} finally {
|
||||
if (priorMode === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = priorMode;
|
||||
if (priorGate === undefined) delete process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
|
||||
else process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL = priorGate;
|
||||
}
|
||||
const first = await firstRunner.start();
|
||||
assert.equal(first.state, 'running'); assert.equal(first.cursor, 64); assert.equal(first.head, 66); assert.equal(first.caughtUp, false);
|
||||
const caughtUp = await firstRunner.pollNow();
|
||||
assert.equal(caughtUp.cursor, 66); assert.equal(caughtUp.caughtUp, true); assert.equal(caughtUp.tip, chain.at(-1)!.hash);
|
||||
await firstRunner.close(); assert.equal(firstRunner.status().state, 'stopped');
|
||||
const next = await fixtureBlock(chain, state, 52000, pow);
|
||||
await ledger.submit(next);
|
||||
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 66);
|
||||
await assert.rejects(firstRunner.pollNow(), /not running/);
|
||||
const restarted = new CanonicalObserver(observer, ledger, { intervalMs: 60000, pagesPerCycle: 1 });
|
||||
try {
|
||||
const resumed = await restarted.start();
|
||||
assert.equal(resumed.cursor, 67); assert.equal(resumed.head, 67); assert.equal(resumed.caughtUp, true);
|
||||
assert.equal(resumed.tip, next.hash);
|
||||
} finally { await restarted.close(); }
|
||||
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 67);
|
||||
chain.push(next); state = transition(state, next, []);
|
||||
const automatic = new CanonicalObserver(observer, ledger, { intervalMs: 25, pagesPerCycle: 1 });
|
||||
try {
|
||||
assert.equal((await automatic.start()).cursor, 67);
|
||||
const automaticBlock = await fixtureBlock(chain, state, 52001, pow);
|
||||
await ledger.submit(automaticBlock);
|
||||
const deadline = Date.now() + 3000;
|
||||
while (automatic.status().cursor < 68 && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
|
||||
assert.equal(automatic.status().cursor, 68, 'scheduled poll did not consume the new ledger event');
|
||||
} finally { await automatic.close(); }
|
||||
} finally { await firstRunner.close(); }
|
||||
});
|
||||
|
||||
test('canonical inbox rejects a disconnected block with a forged but rehashed ancestor', async t => {
|
||||
const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
|
||||
const streamId = randomUUID(), events = canonicalEvents(streamId);
|
||||
|
||||
Reference in New Issue
Block a user