Separate one-shot utility

This commit is contained in:
2026-10-20 23:04:29 -04:00
parent ccc6eef951
commit 5b21074462
18 changed files with 462 additions and 30 deletions
+89 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { Pool, type PoolConfig } from 'pg';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { access, mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises';
import { access, lstat, mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
@@ -19,6 +19,7 @@ import { importSqliteCustody } from '../../server/market/custody-import.ts';
import { postgresConfig, custodyStorage } from '../../server/market/postgres-config.ts';
import { openCustodyStorage } from '../../server/market/custody-storage.ts';
import { createBackup } from '../../server/recovery.ts';
import { createCoordinatedCustodyBackup, verifyCoordinatedCustodyBackup } from '../../server/coordinated-backup.ts';
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand, type CustodyActivity,
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
import { NETWORK } from '../../shared/network.ts';
@@ -367,6 +368,93 @@ test('full PostgreSQL dump/isolated restore preserves custody identity, receipts
}
});
test('coordinated node/PostgreSQL backup holds both writer fences and verifies matching custodian identity', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const dir = await mkdtemp(join(tmpdir(), 'wikideck-coordinated-backup-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const data = join(dir, 'node-data'), networkDir = join(data, 'testnet');
await mkdir(networkDir, { recursive: true });
const ledgerDb = new DatabaseSync(join(networkDir, 'ledger.sqlite'));
try {
ledgerDb.exec(`CREATE TABLE metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
INSERT INTO metadata VALUES('schema_version','1'),('network','${NETWORK}'),('chain_id','${CHAIN_ID}');
CREATE TABLE blocks(sequence INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,previous_hash TEXT NOT NULL,block_json TEXT NOT NULL);
CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`);
} finally { ledgerDb.close(); }
const marketDb = new DatabaseSync(join(networkDir, 'market.sqlite'));
try {
marketDb.exec('CREATE TABLE market_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL)');
marketDb.prepare('INSERT INTO market_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1,
network: NETWORK, chainId: CHAIN_ID, id: randomUUID() }));
} finally { marketDb.close(); }
await writeFile(join(networkDir, 'custody-postgres.json'), JSON.stringify({ version: 1, custodianId: f.market.id }), { mode: 0o600 });
// Model a legitimate post-cutover node: its frozen SQLite source is historical and PostgreSQL is authoritative.
const cutoverDigest = 'a'.repeat(64), legacyDb = new DatabaseSync(join(networkDir, 'custody-sandbox.sqlite'));
try {
legacyDb.exec('CREATE TABLE custody_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL)');
legacyDb.prepare('INSERT INTO custody_meta VALUES(?,?)').run('postgres_cutover', cutoverDigest);
} finally { legacyDb.close(); }
await f.admin.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', ['import_source', cutoverDigest]);
const runtimeUrl = join(dir, 'runtime-url'), backupUrl = join(dir, 'backup-url');
await writeFile(runtimeUrl, 'postgresql://custody_runtime@127.0.0.1/custody_test', { mode: 0o600 });
const backupRole = `custody_backup_${randomUUID().replaceAll('-', '')}`;
await f.admin.query(`CREATE ROLE ${backupRole} LOGIN; GRANT CONNECT ON DATABASE custody_test TO ${backupRole};
GRANT USAGE ON SCHEMA wk_custody TO ${backupRole}; GRANT SELECT ON ALL TABLES IN SCHEMA wk_custody TO ${backupRole};
GRANT SELECT ON ALL SEQUENCES IN SCHEMA wk_custody TO ${backupRole}`);
await writeFile(backupUrl, `postgresql://${backupRole}@127.0.0.1/custody_test`, { mode: 0o600 });
const env = { ...process.env, WIKIDECK_CUSTODY_PG_URL_FILE: runtimeUrl,
WIKIDECK_CUSTODY_PG_BACKUP_URL_FILE: backupUrl, WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' };
const destination = join(dir, 'paired-backup');
const manifest = await createCoordinatedCustodyBackup(data, destination, env);
assert.equal(manifest.custodianId, f.market.id); assert.equal(manifest.database, 'custody_test');
assert.equal(manifest.sqliteCutoverSourceDigest, cutoverDigest);
assert.equal((await verifyCoordinatedCustodyBackup(destination)).nodeManifestSha256, manifest.nodeManifestSha256);
await assert.rejects(lstat(join(destination, 'node', 'custody-sandbox.sqlite')), { code: 'ENOENT' });
const restoreDatabase = `pair_restore_${randomUUID().replaceAll('-', '')}`, run = promisify(execFile);
await f.admin.query(`CREATE DATABASE "${restoreDatabase}"`);
try {
await run('pg_restore', ['-h', '127.0.0.1', '-U', 'postgres', '-d', restoreDatabase, '--exit-on-error', join(destination, 'custody.dump')]);
const restored = await PostgresCustody.open({ ...f.runtime, database: restoreDatabase }, options);
try {
assert.equal(restored.id, f.market.id);
assert.deepEqual(await restored.audit(), await f.market.audit());
assert.equal(await restored.importSource(), cutoverDigest);
const activity = await restored.execute(envelope(restored.id, buyer, { type: 'activity', offset: 0 })) as CustodyActivity;
assert.equal(activity.availableAtomicXmr, '1000000000000');
} finally { await restored.close(); }
} finally { await f.admin.query(`DROP DATABASE IF EXISTS "${restoreDatabase}" WITH (FORCE)`); }
const backupRolePool = new Pool({ ...config, user: backupRole });
try {
await assert.rejects(backupRolePool.query("UPDATE wk_custody.accounts SET balance=0"));
await assert.rejects(backupRolePool.query("DELETE FROM wk_custody.entries"));
await assert.rejects(backupRolePool.query("SELECT nextval('wk_custody.journals_sequence_seq')"));
} finally { await backupRolePool.end(); }
assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000');
await assert.rejects(createCoordinatedCustodyBackup(data, destination, env), /already exists/);
const staleLock = join(data, 'testnet-node.lock'), lockedDestination = join(dir, 'locked-backup');
await writeFile(staleLock, 'operator-lock');
await assert.rejects(createCoordinatedCustodyBackup(data, lockedDestination, env), /Node directory is locked/);
await assert.rejects(lstat(lockedDestination)); await rm(staleLock);
const failedDump = join(dir, 'failed-dump'), brokenEnv = { ...env, WIKIDECK_PG_DUMP_BINARY: join(dir, 'missing-pg-dump') };
await assert.rejects(createCoordinatedCustodyBackup(data, failedDump, brokenEnv), /dump utility failed to start/);
await assert.rejects(verifyCoordinatedCustodyBackup(failedDump), /manifest/);
// A failed external dump releases PostgreSQL's shared-writer fence; normal sandbox commands still work.
const recoveredWrite = await f.execute(other, { type: 'mock-credit' }) as CustodyActivity;
assert.equal(recoveredWrite.availableAtomicXmr, '1000000000000');
const mismatchedBinding = join(dir, 'mismatch-binding'), bindingPath = join(networkDir, 'custody-postgres.json');
const originalBinding = await readFile(bindingPath, 'utf8');
await writeFile(bindingPath, JSON.stringify({ version: 1, custodianId: randomUUID() }), { mode: 0o600 });
await assert.rejects(createCoordinatedCustodyBackup(data, mismatchedBinding, env), /identities differ/);
await writeFile(bindingPath, originalBinding, { mode: 0o600 });
await assert.rejects(verifyCoordinatedCustodyBackup(mismatchedBinding), /manifest/);
const corruptDump = join(destination, 'custody.dump');
await writeFile(corruptDump, 'corrupt');
await assert.rejects(verifyCoordinatedCustodyBackup(destination), /hash\/size mismatch/);
});
test('failed cutover freezes the source, rolls back the entire target, and resumes without duplicate balances', async t => {
const f = await fixture(t); await f.admin.query('DELETE FROM wk_custody.custody_meta');
const dir = await mkdtemp(join(tmpdir(), 'custody-pg-failed-import-')); t.after(() => rm(dir, { recursive: true, force: true }));