70 lines
5.0 KiB
TypeScript
Executable File
70 lines
5.0 KiB
TypeScript
Executable File
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdtemp,rm,writeFile,readFile,mkdir,access,stat } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { Ledger } from '../server/testnet/ledger.ts';
|
|
import { NETWORK } from '../shared/network.ts';
|
|
import { CHAIN_ID } from '../server/testnet/consensus.ts';
|
|
import { createBackup,verifyBackup,restoreBackup } from '../server/recovery.ts';
|
|
|
|
test('stopped-node backup snapshots only the ledger and replays it in an isolated destination',async t=>{
|
|
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
|
const source=join(root,'source');
|
|
const pow={hash:async()=>{throw new Error('Empty ledger should require no proof work');},close(){}};
|
|
const ledger=await Ledger.open(source,pow),snapshot=ledger.snapshot();ledger.close();
|
|
const backup=join(root,'backup');const manifest=await createBackup(source,backup);
|
|
assert.equal(manifest.version,2);assert.deepEqual(manifest.files.map(file=>file.name),['ledger.sqlite']);
|
|
assert.deepEqual(await verifyBackup(backup),manifest);
|
|
const dest=join(root,'restore');assert.equal((await restoreBackup(backup,dest)).proofsRevalidated,false);
|
|
const restored=await Ledger.open(dest,pow);
|
|
assert.deepEqual(restored.snapshot(),snapshot);restored.close();
|
|
await assert.rejects(restoreBackup(backup,dest),/EEXIST/);
|
|
await assert.rejects(createBackup(source,backup),/EEXIST/);
|
|
await assert.rejects(createBackup(source,join(source,'inside')),/outside/);
|
|
await writeFile(join(source,'testnet-node.lock'),'simulated-running');
|
|
await assert.rejects(createBackup(source,join(root,'locked-backup')),/locked/);
|
|
assert.equal(await readFile(join(source,'testnet-node.lock'),'utf8'),'simulated-running');
|
|
});
|
|
|
|
test('new ledger backup refuses to omit legacy journals, and old paired backups remain portable',async t=>{
|
|
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-legacy-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
|
const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}});ledger.close();
|
|
const legacyFile=join(source,'testnet','market.sqlite');
|
|
const legacyDb=new DatabaseSync(legacyFile);legacyDb.exec('CREATE TABLE legacy_archive(value TEXT)');legacyDb.close();
|
|
await assert.rejects(createBackup(source,join(root,'blocked-backup')),/Legacy financial data/);
|
|
|
|
const backup=join(root,'legacy-backup');await mkdir(backup);
|
|
const ledgerCopy=join(backup,'ledger.sqlite');
|
|
const ledgerDb=new DatabaseSync(join(source,'testnet','ledger.sqlite'),{readOnly:true});
|
|
try{ledgerDb.prepare('VACUUM INTO ?').run(ledgerCopy);}finally{ledgerDb.close();}
|
|
const marketCopy=join(backup,'market.sqlite');
|
|
const marketDb=new DatabaseSync(legacyFile,{readOnly:true});
|
|
try{marketDb.prepare('VACUUM INTO ?').run(marketCopy);}finally{marketDb.close();}
|
|
const fileInfo=async(name:string,path:string)=>({name,bytes:(await stat(path)).size,
|
|
sha256:createHash('sha256').update(await readFile(path)).digest('hex')});
|
|
const oldManifest={format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,
|
|
profile:'testnet',createdAt:new Date().toISOString(),files:[await fileInfo('ledger.sqlite',ledgerCopy),await fileInfo('market.sqlite',marketCopy)]};
|
|
await writeFile(join(backup,'manifest.json'),JSON.stringify(oldManifest));
|
|
assert.equal((await verifyBackup(backup)).version,1);
|
|
const restored=join(root,'legacy-restore');await restoreBackup(backup,restored);
|
|
assert.equal((await stat(join(restored,'testnet','market.sqlite'))).size,(await stat(marketCopy)).size);
|
|
});
|
|
|
|
test('backup verifier rejects tampering, sidecars, network mismatch and incomplete packages',async t=>{
|
|
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-bad-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
|
const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}});
|
|
ledger.close();const backup=join(root,'backup');await createBackup(source,backup);
|
|
const original=await readFile(join(backup,'manifest.json'),'utf8');
|
|
await writeFile(join(backup,'ledger.sqlite-wal'),'unexpected');await assert.rejects(verifyBackup(backup),/sidecar/);
|
|
await rm(join(backup,'ledger.sqlite-wal'));
|
|
await writeFile(join(backup,'manifest.json'),original.replace('wikideck-randomx-testnet-6','wrong-network'));await assert.rejects(verifyBackup(backup));
|
|
await writeFile(join(backup,'manifest.json'),original);
|
|
await writeFile(join(backup,'ledger.sqlite'),Buffer.concat([await readFile(join(backup,'ledger.sqlite')),Buffer.from('tampered')]));
|
|
await assert.rejects(verifyBackup(backup),/hash\/size/);
|
|
const missing=join(root,'incomplete');await mkdir(missing);await assert.rejects(verifyBackup(missing));
|
|
await assert.rejects(access(join(source,'testnet-node.lock'))); // maintenance lock released, not silently left behind
|
|
});
|