Separate one-shot utility
This commit is contained in:
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user