mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
49c3c57c31
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup timer only because it happened to be the domain that mints tokens. Issuing them and sweeping them are different jobs, and FilesService and TokenService mint or consume them too. EphemeralTokenService owns the lifecycle and hands the three operations out. The store and the interval stay module state next door, and that is load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on the upgrade handshake, outside the container, before any injector is in reach. Per-instance state would hand it an empty Map and close every connection with 4001, which no test would catch because the ws suites drive the transport directly. WS-027 through WS-029 pin the session gate that runs through exactly that path. The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken returns null at 10,000 live tokens and TokenService turns that into a 503. A second store would raise the real ceiling with nobody noticing. Its own leaf module rather than a provider in AuthModule: TokensModule mints download and ws tokens as well, AuthModule already imports TokensModule, and TokensModule is documented as a deliberate leaf that three graphs reach for. Same shape as MailerModule, which broke AuthModule <-> NotificationsModule. Twelve hand-wired construction sites in tests needed the new collaborator. The typecheck named all twelve; before this branch they would have been undefined at runtime. Sixteen more test files are cleared here too, each at its root cause. Four of them were passing for the wrong reason: files.controller.test.ts drove two handlers with shifted arguments, a string tripId where the trip row belongs and undefined where tripId and id belong. It stayed green because FilesService is fully stubbed and the stubs ignore what they are given, so DELETE /:id/permanent and DELETE /trash/empty never covered that the trip row reaches the permission check. conflictUpdate.test.ts was missing real collaborators on PlacesService and PackingService. None of the exercised paths reach them, so it passed by luck. assignments.service.test.ts builds its service by hand, and the one consumer of the collaborator it was missing swallows every throw in a bare catch. A broken journey reconciliation would still have shown 28 green tests. scheduler.test.ts mocks two modules with paths that resolve one directory above the server root, so they mock nothing at all. Harmless only because src/scheduler.ts imports neither.
190 lines
7.9 KiB
TypeScript
190 lines
7.9 KiB
TypeScript
/**
|
|
* Scheduled backup run.
|
|
*
|
|
* The auto-backup used to archive the live travel.db: the archiver reads its
|
|
* entries while streaming, so a WAL auto-checkpoint writing pages back mid-run
|
|
* left a torn copy in the zip — without the -wal that would make it recoverable.
|
|
* It also shipped without .encryption_key, so the archive could not be decrypted
|
|
* on another install. The run now goes through backupService.createBackup(),
|
|
* which snapshots the DB with VACUUM INTO and bundles the key.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { PathLike } from 'node:fs';
|
|
|
|
const fsMock = vi.hoisted(() => ({
|
|
// The parameter is declared so the double carries fs's real signature: the
|
|
// cases below replace this default with a path-dependent implementation.
|
|
existsSync: vi.fn((_p: PathLike) => false),
|
|
mkdirSync: vi.fn(),
|
|
readFileSync: vi.fn(() => '{}'),
|
|
writeFileSync: vi.fn(),
|
|
readdirSync: vi.fn(() => [] as unknown as string[]),
|
|
statSync: vi.fn(() => ({ size: 0, birthtime: new Date(0), mtimeMs: 0 })),
|
|
unlinkSync: vi.fn(),
|
|
createWriteStream: vi.fn(),
|
|
createReadStream: vi.fn(),
|
|
rmSync: vi.fn(),
|
|
copyFileSync: vi.fn(),
|
|
renameSync: vi.fn(),
|
|
cpSync: vi.fn(),
|
|
realpathSync: vi.fn((p: string) => p),
|
|
}));
|
|
|
|
const archiveMock = vi.hoisted(() => ({
|
|
pipe: vi.fn(),
|
|
file: vi.fn(),
|
|
directory: vi.fn(),
|
|
glob: vi.fn(),
|
|
finalize: vi.fn(),
|
|
on: vi.fn(),
|
|
}));
|
|
|
|
const archiverMock = vi.hoisted(() => vi.fn());
|
|
|
|
const dbMock = vi.hoisted(() => ({
|
|
db: { exec: vi.fn(), prepare: vi.fn() },
|
|
closeDb: vi.fn(),
|
|
reinitialize: vi.fn(),
|
|
}));
|
|
|
|
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn() }));
|
|
|
|
const cronMock = vi.hoisted(() => ({ schedule: vi.fn(), validate: vi.fn(() => true) }));
|
|
|
|
vi.mock('node-cron', () => ({ default: cronMock, ...cronMock }));
|
|
vi.mock('fs', () => ({ default: fsMock, ...fsMock }));
|
|
vi.mock('node:fs', () => ({ default: fsMock, ...fsMock }));
|
|
vi.mock('archiver', () => ({ default: archiverMock }));
|
|
vi.mock('unzipper', () => ({ default: { Extract: vi.fn(), Open: { file: vi.fn() } } }));
|
|
vi.mock('../../src/db/database', () => dbMock);
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => logMock);
|
|
vi.mock('../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a'.repeat(64),
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
import path from 'node:path';
|
|
import { start, setSchedulerDeps } from '../../src/scheduler';
|
|
import { createBackup } from '../../src/nest/backup/backup.impl';
|
|
|
|
const liveDb = path.join(__dirname, '../../data', 'travel.db');
|
|
|
|
/** Wires up the archiver so finalize() resolves the run, and returns its events. */
|
|
function stubArchiver(): Record<string, (arg?: unknown) => void> {
|
|
const outputEvents: Record<string, (arg?: unknown) => void> = {};
|
|
const archiveEvents: Record<string, (arg?: unknown) => void> = {};
|
|
fsMock.createWriteStream.mockReturnValue({
|
|
on: vi.fn((event: string, cb: () => void) => { outputEvents[event] = cb; }),
|
|
} as never);
|
|
archiveMock.on.mockImplementation((event: string, cb: (arg?: unknown) => void) => { archiveEvents[event] = cb; });
|
|
archiveMock.finalize.mockImplementation(() => { outputEvents['close']?.(); });
|
|
archiverMock.mockReturnValue(archiveMock);
|
|
return archiveEvents;
|
|
}
|
|
|
|
/** Starts the scheduler with auto-backup enabled and hands back the cron callback. */
|
|
function scheduledRun(): () => Promise<void> {
|
|
fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 }));
|
|
// The same wiring index.ts does from the container, with the real service
|
|
// function behind it — the run below is still the production code path.
|
|
// Only the backup tick is started here; the scheduler's other two container
|
|
// collaborators are stubbed so the deps object is complete, and neither the
|
|
// place-photo sweep nor the AirTrail tick is scheduled by start().
|
|
setSchedulerDeps({
|
|
backups: { createBackup },
|
|
placePhotos: { sweepOrphans: vi.fn(() => 0) },
|
|
airtrail: { runAirtrailSync: vi.fn(async () => {}) },
|
|
});
|
|
start();
|
|
return cronMock.schedule.mock.calls.at(-1)?.[1] as () => Promise<void>;
|
|
}
|
|
|
|
describe('auto-backup run', () => {
|
|
const envKey = process.env.ENCRYPTION_KEY;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// No ENCRYPTION_KEY in the env means the key file is the source of truth and
|
|
// has to travel with the backup.
|
|
delete process.env.ENCRYPTION_KEY;
|
|
fsMock.statSync.mockReturnValue({ size: 4096, birthtime: new Date('2026-04-27T02:00:00Z'), mtimeMs: Date.now() } as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.ENCRYPTION_KEY = envKey;
|
|
});
|
|
|
|
it('archives a VACUUM INTO snapshot, never the live travel.db', async () => {
|
|
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db') || String(p).endsWith('backup-settings.json'));
|
|
stubArchiver();
|
|
|
|
await scheduledRun()();
|
|
|
|
expect(dbMock.db.exec).toHaveBeenCalledWith(expect.stringContaining('VACUUM INTO'));
|
|
const dbEntry = archiveMock.file.mock.calls.find(([, opts]) => opts?.name === 'travel.db');
|
|
expect(dbEntry).toBeDefined();
|
|
expect(dbEntry?.[0]).toContain('.travel-snap-auto-backup-');
|
|
expect(dbEntry?.[0]).not.toBe(liveDb);
|
|
});
|
|
|
|
it('bundles the at-rest encryption key', async () => {
|
|
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key') || String(p).endsWith('backup-settings.json'));
|
|
stubArchiver();
|
|
|
|
await scheduledRun()();
|
|
|
|
expect(archiveMock.file).toHaveBeenCalledWith(
|
|
expect.stringContaining('.encryption_key'),
|
|
{ name: '.encryption_key' },
|
|
);
|
|
});
|
|
|
|
it('keeps the auto-backup-*.zip naming so retention still prunes it', async () => {
|
|
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('backup-settings.json'));
|
|
fsMock.readdirSync.mockReturnValue(['auto-backup-2020-01-01T02-00-00.zip'] as unknown as string[]);
|
|
stubArchiver();
|
|
|
|
await scheduledRun()();
|
|
|
|
expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringMatching(/^Auto-Backup created: auto-backup-[\dT-]+\.zip$/));
|
|
// cleanupOldBackups(keep_days) still runs after the archive and only sees
|
|
// files it can match by prefix
|
|
expect(fsMock.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('auto-backup-2020-01-01T02-00-00.zip'));
|
|
});
|
|
|
|
it('logs the failure, drops the partial zip and skips retention', async () => {
|
|
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.zip') || String(p).endsWith('backup-settings.json'));
|
|
const archiveEvents = stubArchiver();
|
|
archiveMock.finalize.mockImplementation(() => { archiveEvents['error']?.(new Error('disk full')); });
|
|
|
|
await scheduledRun()();
|
|
|
|
expect(logMock.logError).toHaveBeenCalledWith('Auto-Backup: disk full');
|
|
expect(logMock.logInfo).not.toHaveBeenCalledWith(expect.stringContaining('Auto-Backup created'));
|
|
expect(fsMock.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('auto-backup-'));
|
|
// the snapshot scratch file is cleaned up too, and retention never runs
|
|
expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('.travel-snap-auto-backup-'), { force: true });
|
|
expect(fsMock.readdirSync).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('auto-backup without container dependencies', () => {
|
|
it('logs and skips instead of throwing when setSchedulerDeps was never called', async () => {
|
|
// A fresh module registry, so the deps this file's other cases install are
|
|
// not still sitting in the scheduler's module state.
|
|
vi.resetModules();
|
|
const { start: freshStart } = await import('../../src/scheduler');
|
|
|
|
vi.clearAllMocks();
|
|
fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 }));
|
|
freshStart();
|
|
await (cronMock.schedule.mock.calls.at(-1)?.[1] as () => Promise<void>)();
|
|
|
|
expect(logMock.logError).toHaveBeenCalledWith(
|
|
'Auto-Backup: skipped, the scheduler was started without its container dependencies',
|
|
);
|
|
expect(archiverMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|