170 lines
7.4 KiB
TypeScript
170 lines
7.4 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 { ConfigService } from '@nestjs/config';
|
|
|
|
// ConfigService reads process.env with precedence over its internal
|
|
// overrides, so unset the env-driven defaults before constructing it
|
|
// for these tests so the per-test data roots are honored.
|
|
delete process.env.DATABASE_PATH;
|
|
delete process.env.UPLOAD_DIR;
|
|
delete process.env.SYSTEM_OP_STAGING_DIR;
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { DatabaseInitService } from '../../backend/src/database/database-init.service';
|
|
import {
|
|
FilesystemTransactionService,
|
|
TransactionManifest,
|
|
} from '../../backend/src/modules/admin/system/filesystem-transaction.service';
|
|
import { ERROR_CODES } from '../../backend/src/common/errors/error-codes';
|
|
|
|
function makeRoot(prefix: string): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
describe('Job 919: DatabaseInitService recovery on startup', () => {
|
|
let work: string;
|
|
let dbPath: string;
|
|
let uploadDir: string;
|
|
let stagingDir: string;
|
|
|
|
beforeEach(() => {
|
|
work = makeRoot('hipctf-job919-init-');
|
|
dbPath = path.join(work, 'db.sqlite');
|
|
uploadDir = path.join(work, 'uploads');
|
|
stagingDir = path.join(work, '.system-staging');
|
|
// Note: `uploadDir` is intentionally NOT created here. Individual tests
|
|
// arrange the filesystem in the exact on-disk shape they want to
|
|
// exercise (e.g. an interrupted-swap manifest expects the live uploads
|
|
// dir to NOT exist on disk because the in-process snapshot has moved it).
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (work && fs.existsSync(work)) {
|
|
try { fs.rmSync(work, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
function buildInit(): DatabaseInitService {
|
|
const config = new ConfigService({
|
|
DATABASE_PATH: dbPath,
|
|
UPLOAD_DIR: uploadDir,
|
|
SYSTEM_OP_STAGING_DIR: stagingDir,
|
|
} as any);
|
|
const fsTx = new FilesystemTransactionService();
|
|
// The DataSource is not used by the recovery code path, so a stub suffices.
|
|
const fakeDataSource: any = { isInitialized: true, query: async () => [] };
|
|
const init = new DatabaseInitService(fakeDataSource, config, fsTx);
|
|
return init;
|
|
}
|
|
|
|
it('restores the old database + uploads generation from an interrupted restore manifest', async () => {
|
|
const stamp = `${Date.now()}-11111`;
|
|
const liveDb = dbPath;
|
|
const liveUploads = uploadDir;
|
|
const oldDbRollback = `${liveDb}.rollback-${stamp}`;
|
|
const oldUpRollback = `${liveUploads}.rollback-${stamp}`;
|
|
const stagedDb = path.join(stagingDir, 'unused.db');
|
|
const stagedUploads = path.join(stagingDir, 'unused.up');
|
|
|
|
// Simulate the in-process state AFTER the live-snapshot pair rename has
|
|
// moved the live tree to the rollback location but BEFORE the staged
|
|
// replacement has been moved into place.
|
|
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()}-tyy`;
|
|
const txDir = path.join(stagingDir, 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: 'live-snapshotted',
|
|
pairs: [
|
|
{ livePath: liveDb, rollbackPath: oldDbRollback, stagedPath: stagedDb, contributesToGeneration: true, sidecarSuffixes: ['-wal', '-shm'] },
|
|
{ livePath: liveUploads, rollbackPath: oldUpRollback, stagedPath: stagedUploads, contributesToGeneration: true, sidecarSuffixes: [] },
|
|
],
|
|
};
|
|
fs.writeFileSync(path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME), JSON.stringify(manifest));
|
|
|
|
const init = buildInit();
|
|
await (init as any).recoverInterruptedSystemTransactions();
|
|
|
|
expect(fs.readFileSync(liveDb, 'utf8')).toBe('OLD-DB-SNAPSHOT');
|
|
expect(fs.readFileSync(path.join(liveUploads, 'keep.txt'), 'utf8')).toBe('OLD-UPLOAD-SNAPSHOT');
|
|
expect(fs.existsSync(path.join(liveUploads, 'replaced.txt'))).toBe(false);
|
|
expect(fs.existsSync(oldDbRollback)).toBe(false);
|
|
expect(fs.existsSync(oldUpRollback)).toBe(false);
|
|
expect(fs.existsSync(txDir)).toBe(false);
|
|
});
|
|
|
|
it('fails closed (throws) when an interrupted manifest has a hybrid state (no full old or new generation)', async () => {
|
|
const stamp = `${Date.now()}-22222`;
|
|
const oldDbRollback = `${dbPath}.rollback-${stamp}`;
|
|
fs.writeFileSync(oldDbRollback, 'OLD-DB-SNAPSHOT');
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
fs.writeFileSync(path.join(uploadDir, 'replaced.txt'), 'NEW-ORPHAN');
|
|
const oldUpRollback = `${uploadDir}.rollback-${stamp}`;
|
|
fs.mkdirSync(oldUpRollback, { recursive: true });
|
|
fs.writeFileSync(path.join(oldUpRollback, 'keep.txt'), 'OLD-UPLOAD-SNAPSHOT');
|
|
|
|
const id = `${Date.now()}-hyb`;
|
|
const txDir = path.join(stagingDir, 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: dbPath, rollbackPath: oldDbRollback, stagedPath: path.join(stagingDir, 'unused.db'), contributesToGeneration: true, sidecarSuffixes: [] },
|
|
{ livePath: uploadDir, rollbackPath: oldUpRollback, stagedPath: path.join(stagingDir, 'unused.up'), contributesToGeneration: true, sidecarSuffixes: [] },
|
|
],
|
|
};
|
|
fs.writeFileSync(path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME), JSON.stringify(manifest));
|
|
|
|
const init = buildInit();
|
|
await expect((init as any).recoverInterruptedSystemTransactions()).rejects.toMatchObject({
|
|
code: ERROR_CODES.SYSTEM_RESTORE_FAILED,
|
|
});
|
|
|
|
expect(fs.readFileSync(oldDbRollback, 'utf8')).toBe('OLD-DB-SNAPSHOT');
|
|
expect(fs.readFileSync(path.join(uploadDir, 'replaced.txt'), 'utf8')).toBe('NEW-ORPHAN');
|
|
expect(fs.readFileSync(path.join(oldUpRollback, 'keep.txt'), 'utf8')).toBe('OLD-UPLOAD-SNAPSHOT');
|
|
expect(fs.existsSync(txDir)).toBe(true);
|
|
});
|
|
|
|
it('removes stale unowned rollback/restore-stage/wipe-stage artifacts during startup sweep', async () => {
|
|
const now = Date.now();
|
|
const oldStamp = now - 2 * 24 * 60 * 60_000; // 2 days old
|
|
|
|
const staleDbRollback = path.join(work, `.db.sqlite.rollback-${oldStamp}-abc`);
|
|
const staleUploadsRollback = `${uploadDir}.rollback-${oldStamp}-def`;
|
|
const staleUploadsStage = `${uploadDir}.restore-stage-${oldStamp}-ghi`;
|
|
fs.writeFileSync(staleDbRollback, 'orphan');
|
|
fs.mkdirSync(staleUploadsRollback, { recursive: true });
|
|
fs.mkdirSync(staleUploadsStage, { recursive: true });
|
|
const oldTime = new Date(oldStamp);
|
|
fs.utimesSync(staleDbRollback, oldTime, oldTime);
|
|
fs.utimesSync(staleUploadsRollback, oldTime, oldTime);
|
|
fs.utimesSync(staleUploadsStage, oldTime, oldTime);
|
|
|
|
const init = buildInit();
|
|
await (init as any).recoverInterruptedSystemTransactions();
|
|
|
|
expect(fs.existsSync(staleDbRollback)).toBe(false);
|
|
expect(fs.existsSync(staleUploadsRollback)).toBe(false);
|
|
expect(fs.existsSync(staleUploadsStage)).toBe(false);
|
|
});
|
|
});
|