Files
TREK/server/tests/unit/auto-backup.test.ts
T
Maurice 49c3c57c31 Give the ephemeral tokens a provider, and clear the next wave of type errors
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.
2026-08-10 19:54:46 +02:00

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();
});
});