Files
HIPCTF2/tests/backend/filesystem-transaction.spec.ts
T

301 lines
13 KiB
TypeScript

process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { FilesystemTransactionService } from '../../backend/src/modules/admin/system/filesystem-transaction.service';
import { ERROR_CODES } from '../../backend/src/common/errors/error-codes';
import { TransactionManifest } from '../../backend/src/modules/admin/system/filesystem-transaction.service';
function makeRoot(prefix: string): { root: string; cleanup: () => void } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
return {
root,
cleanup: () => {
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
},
};
}
function readManifest(stagingDir: string, id: string): TransactionManifest {
const full = path.join(stagingDir, id, FilesystemTransactionService.MANIFEST_FILENAME);
return JSON.parse(fs.readFileSync(full, 'utf8')) as TransactionManifest;
}
describe('Job 919: FilesystemTransactionService durable manifest', () => {
let svc: FilesystemTransactionService;
let dataRoot: { root: string; cleanup: () => void };
beforeEach(() => {
svc = new FilesystemTransactionService();
dataRoot = makeRoot('hipctf-fs-tx-');
});
afterEach(() => {
dataRoot.cleanup();
});
it('stageSwap + commit leaves the new generation in place and removes rollback/staged artifacts', async () => {
const live = path.join(dataRoot.root, 'live.txt');
const staged = path.join(dataRoot.root, 'staged.txt');
fs.writeFileSync(live, 'OLD');
fs.writeFileSync(staged, 'NEW');
const handle = await svc.stageSwap(
{
name: 'unit-swap',
pairs: [{ livePath: live, stagedPath: staged, contributesToGeneration: true }],
},
{ stagingDir: dataRoot.root },
);
expect(handle.transactionId).toBeTruthy();
expect(fs.readFileSync(live, 'utf8')).toBe('NEW');
// Manifest exists and is at the `verified` phase before commit
const manifest = readManifest(dataRoot.root, handle.transactionId!);
expect(manifest.phase).toBe('verified');
svc.commit(handle);
expect(fs.existsSync(handle.manifestDir!)).toBe(false);
expect(fs.readFileSync(live, 'utf8')).toBe('NEW');
});
it('stageSwap failure rolls the live path back to its prior content and marks manifest rolled-back', async () => {
const dataDir = path.join(dataRoot.root, 'data');
fs.mkdirSync(dataDir, { recursive: true });
const stagingRoot = path.join(dataRoot.root, '.system-staging');
fs.mkdirSync(stagingRoot, { recursive: true });
const live = path.join(dataDir, 'live.bin');
const staged = path.join(dataDir, 'staged.bin');
fs.writeFileSync(live, 'OLD-CONTENT');
fs.writeFileSync(staged, 'NEW-CONTENT');
await expect(
svc.stageSwap(
{
name: 'unit-swap-fail',
pairs: [{ livePath: live, stagedPath: staged, contributesToGeneration: true }],
hooks: {
beforeSwap: async () => {
throw new Error('boom');
},
},
},
{ stagingDir: stagingRoot },
),
).rejects.toMatchObject({ message: 'boom' });
expect(fs.readFileSync(live, 'utf8')).toBe('OLD-CONTENT');
const manifestDirs = fs.readdirSync(stagingRoot);
expect(manifestDirs.length).toBe(1);
const manifest = readManifest(stagingRoot, manifestDirs[0]);
expect(manifest.phase).toBe('rolled-back');
});
it('recoverTransactions restores the OLD generation when the manifest is at live-snapshotted and both rollback dirs are intact', () => {
const liveDb = path.join(dataRoot.root, 'db.sqlite');
const stagedDb = path.join(dataRoot.root, 'db.staged.sqlite');
fs.writeFileSync(liveDb, 'OLD-DB');
fs.writeFileSync(stagedDb, 'NEW-DB');
const liveUploads = path.join(dataRoot.root, 'uploads');
const stagedUploads = path.join(dataRoot.root, 'uploads.stage');
fs.mkdirSync(liveUploads, { recursive: true });
fs.mkdirSync(stagedUploads, { recursive: true });
fs.writeFileSync(path.join(liveUploads, 'keep.txt'), 'OLD-UPLOAD');
fs.writeFileSync(path.join(stagedUploads, 'replaced.txt'), 'NEW-UPLOAD');
const id = `${Date.now()}-abcdef`;
const txDir = path.join(dataRoot.root, id);
fs.mkdirSync(txDir, { recursive: true });
const stamp = `${Date.now()}-12345`;
const manifest: TransactionManifest = {
schemaVersion: 1,
transactionId: id,
operation: 'restore-backup',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
phase: 'live-snapshotted',
pairs: [
{
livePath: liveDb,
rollbackPath: `${liveDb}.rollback-${stamp}`,
stagedPath: stagedDb,
contributesToGeneration: true,
sidecarSuffixes: ['-wal', '-shm'],
},
{
livePath: liveUploads,
rollbackPath: `${liveUploads}.rollback-${stamp}`,
stagedPath: stagedUploads,
contributesToGeneration: true,
sidecarSuffixes: [],
},
],
};
fs.writeFileSync(path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME), JSON.stringify(manifest));
// Simulate the in-progress state where the snapshot-to-rollback has already happened.
fs.renameSync(liveDb, manifest.pairs[0].rollbackPath);
fs.renameSync(liveUploads, manifest.pairs[1].rollbackPath);
const report = svc.recoverTransactions(dataRoot.root);
expect(report.transactionsScanned).toBe(1);
expect(report.resolved).toBe(1);
expect(fs.readFileSync(liveDb, 'utf8')).toBe('OLD-DB');
expect(fs.readFileSync(path.join(liveUploads, 'keep.txt'), 'utf8')).toBe('OLD-UPLOAD');
// Staged trees should not have ended up at the live paths.
expect(fs.existsSync(stagedDb)).toBe(true);
expect(fs.existsSync(stagedUploads)).toBe(true);
});
it('recoverTransactions keeps the NEW generation when both pairs swapped but manifest is at verified', () => {
const liveDb = path.join(dataRoot.root, 'db.sqlite');
const liveUploads = path.join(dataRoot.root, 'uploads');
fs.writeFileSync(liveDb, 'NEW-DB');
fs.mkdirSync(liveUploads, { recursive: true });
fs.writeFileSync(path.join(liveUploads, 'replaced.txt'), 'NEW-UPLOAD');
const stamp = `${Date.now()}-99999`;
const oldDbPath = `${liveDb}.rollback-${stamp}`;
const oldUpPath = `${liveUploads}.rollback-${stamp}`;
fs.writeFileSync(oldDbPath, 'OLD-DB');
fs.mkdirSync(oldUpPath, { recursive: true });
fs.writeFileSync(path.join(oldUpPath, 'keep.txt'), 'OLD-UPLOAD');
const id = `${Date.now()}-x9`;
const txDir = path.join(dataRoot.root, id);
fs.mkdirSync(txDir, { recursive: true });
const manifest: TransactionManifest = {
schemaVersion: 1,
transactionId: id,
operation: 'restore-backup',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
phase: 'verified',
pairs: [
{ livePath: liveDb, rollbackPath: oldDbPath, stagedPath: path.join(dataRoot.root, 'unused.db'), contributesToGeneration: true, sidecarSuffixes: [] },
{ livePath: liveUploads, rollbackPath: oldUpPath, stagedPath: path.join(dataRoot.root, 'unused.up'), contributesToGeneration: true, sidecarSuffixes: [] },
],
};
fs.writeFileSync(path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME), JSON.stringify(manifest));
const report = svc.recoverTransactions(dataRoot.root);
expect(report.resolved).toBe(1);
expect(fs.readFileSync(liveDb, 'utf8')).toBe('NEW-DB');
expect(fs.readFileSync(path.join(liveUploads, 'replaced.txt'), 'utf8')).toBe('NEW-UPLOAD');
// Rollback trees for the (now finalized) new generation must be gone.
expect(fs.existsSync(oldDbPath)).toBe(false);
expect(fs.existsSync(oldUpPath)).toBe(false);
});
it('recoverTransactions fails closed when neither generation is fully present (the Job 919 hybrid state)', () => {
// Reproduce the actual reported hybrid: the swap committed the new
// uploads tree to the live path and wrote a rollback of the OLD
// uploads tree, but the DB live→rollback snapshot happened while the
// staged DB never reached the live path. From the resolver's point
// of view the DB pair looks "old-complete" (livePath missing,
// rollbackPath present), the uploads pair looks "new-complete"
// (livePath present, rollbackPath present). Both pairs are not
// consistent; recovery must fail closed without modifying either
// generation.
const dataDir = path.join(dataRoot.root, 'data');
fs.mkdirSync(dataDir, { recursive: true });
const liveDb = path.join(dataDir, 'db.sqlite');
const liveUploads = path.join(dataDir, 'uploads');
fs.mkdirSync(liveUploads, { recursive: true });
fs.writeFileSync(path.join(liveUploads, 'replaced.txt'), 'NEW-ORPHAN'); // stale from a failed swap
const stamp = `${Date.now()}-hybrid`;
const oldDbRollback = `${liveDb}.rollback-${stamp}`;
const oldUpRollback = `${liveUploads}.rollback-${stamp}`;
fs.writeFileSync(oldDbRollback, 'OLD-DB-SNAPSHOT');
fs.mkdirSync(oldUpRollback, { recursive: true });
fs.writeFileSync(path.join(oldUpRollback, 'keep.txt'), 'OLD-UPLOAD-SNAPSHOT');
const id = `${Date.now()}-hybrid`;
const stagingRoot = path.join(dataRoot.root, '.system-staging');
fs.mkdirSync(stagingRoot, { recursive: true });
const txDir = path.join(stagingRoot, id);
fs.mkdirSync(txDir, { recursive: true });
const manifest: TransactionManifest = {
schemaVersion: 1,
transactionId: id,
operation: 'restore-backup',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
phase: 'verified',
pairs: [
{ livePath: liveDb, rollbackPath: oldDbRollback, stagedPath: path.join(dataDir, 'unused.db'), contributesToGeneration: true, sidecarSuffixes: [] },
{ livePath: liveUploads, rollbackPath: oldUpRollback, stagedPath: path.join(dataDir, 'unused.up'), contributesToGeneration: true, sidecarSuffixes: [] },
],
};
fs.writeFileSync(path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME), JSON.stringify(manifest));
expect(() => svc.recoverTransactions(stagingRoot)).toThrow(
expect.objectContaining({ code: ERROR_CODES.SYSTEM_RESTORE_FAILED }),
);
// Fail-closed must NOT delete or move anything in either generation.
expect(fs.readFileSync(oldDbRollback, 'utf8')).toBe('OLD-DB-SNAPSHOT');
expect(fs.readFileSync(path.join(liveUploads, 'replaced.txt'), 'utf8')).toBe('NEW-ORPHAN');
expect(fs.readFileSync(path.join(oldUpRollback, 'keep.txt'), 'utf8')).toBe('OLD-UPLOAD-SNAPSHOT');
// Manifest stays on disk for the operator.
expect(fs.existsSync(txDir)).toBe(true);
});
it('sweepUnownedArtifactsNearLivePaths cleans up stale rollback/restore-stage/wipe-stage directories', () => {
const dataDir = path.join(dataRoot.root, 'data');
fs.mkdirSync(dataDir, { recursive: true });
const liveDb = path.join(dataDir, 'db.sqlite');
fs.writeFileSync(liveDb, 'OLD');
const liveUploads = path.join(dataDir, 'uploads');
fs.mkdirSync(liveUploads, { recursive: true });
const challenges = path.join(liveUploads, 'challenges');
fs.mkdirSync(challenges, { recursive: true });
const now = Date.now();
const oldStamp = now - 2 * 24 * 60 * 60_000; // 2 days old
const freshStamp = now - 60_000; // 1 minute old
const rollbackUploads = `${liveUploads}.rollback-${oldStamp}-abc`;
const rollbackDb = path.join(dataDir, `.db.sqlite.rollback-${oldStamp}-def`);
const stageUploads = `${liveUploads}.restore-stage-${oldStamp}-ghi`;
const wipeChallenges = path.join(challenges, `.challenges.wipe-stage-${oldStamp}-jkl`);
fs.mkdirSync(rollbackUploads, { recursive: true });
fs.writeFileSync(rollbackDb, 'orphan');
fs.mkdirSync(stageUploads, { recursive: true });
fs.mkdirSync(wipeChallenges, { recursive: true });
// A fresh rollback pattern must NOT be removed.
const freshRollbackUploads = `${liveUploads}.rollback-${freshStamp}-mno`;
fs.mkdirSync(freshRollbackUploads, { recursive: true });
// Backdate the old artifacts.
const oldTime = new Date(oldStamp);
fs.utimesSync(rollbackUploads, oldTime, oldTime);
fs.utimesSync(rollbackDb, oldTime, oldTime);
fs.utimesSync(stageUploads, oldTime, oldTime);
fs.utimesSync(wipeChallenges, oldTime, oldTime);
const cleaned = svc.sweepUnownedArtifactsNearLivePaths([liveDb, liveUploads], {
now,
maxAgeMs: 60 * 60_000,
stagingDir: dataRoot.root,
});
expect(cleaned).toEqual(expect.arrayContaining([rollbackUploads, rollbackDb, stageUploads, wipeChallenges]));
expect(fs.existsSync(rollbackUploads)).toBe(false);
expect(fs.existsSync(rollbackDb)).toBe(false);
expect(fs.existsSync(stageUploads)).toBe(false);
expect(fs.existsSync(wipeChallenges)).toBe(false);
// Fresh rollback path remains.
expect(fs.existsSync(freshRollbackUploads)).toBe(true);
// Cleanup helper for the remaining fresh artifact.
try { fs.rmSync(freshRollbackUploads, { recursive: true, force: true }); } catch { /* ignore */ }
});
});