Files
TREK/server/tests/unit/config.test.ts
T
Maurice a7aaa29c16 fix(db): stop losing secrets and notes to the boot and migration paths
An unreadable or empty encryption-key file was treated the same as a missing
one: regenerate and overwrite. That is the one case where regenerating is
the worst possible move - every secret already at rest becomes undecryptable,
permanently. Both now refuse to start and say which file to fix.

The key-rotation script advertised re-encrypting all stored secrets while its
hardcoded column lists had drifted: the AirTrail key, the per-user LLM key
and the plugin OAuth tokens were never migrated, so they silently stopped
decrypting after a rotation. The parity test now pins the full set against
what the code actually encrypts, rather than two tables of it.

The schema_version bump ran outside the migration's transaction, so a crash
in between left the schema advanced and the version stale - the next boot
replayed a non-idempotent step and exited, turning one crash into a boot
loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite
treats case-insensitively, so a note somebody had typed starting 'ticketjson:'
was cut up and its text dropped.

Also: APP_VERSION was interpolated into migration SQL instead of bound, the
capture-metadata backfill passed (photoId, userId) to a (userId, photoId)
signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was
accepted and silently coerced to 1, demo-reset hardcoded the db path instead
of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set
now says out loud that its admin password is the published default.
2026-08-23 11:59:12 +02:00

90 lines
3.9 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// config.ts resolves its key material at import time, so every case here has to
// reset the module registry and re-import it with the file system stubbed out.
const ENC_KEY_FILE = path.resolve(__dirname, '../../data/.encryption_key');
const JWT_SECRET_FILE = path.resolve(__dirname, '../../data/.jwt_secret');
const enoent = (): NodeJS.ErrnoException => {
const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
err.code = 'ENOENT';
return err;
};
describe('config — encryption key resolution', () => {
const realEnvKey = process.env.ENCRYPTION_KEY;
const realRead = fs.readFileSync;
beforeEach(() => {
// The env var short-circuits the whole file-resolution chain under test.
delete process.env.ENCRYPTION_KEY;
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
process.env.ENCRYPTION_KEY = realEnvKey;
});
// Only the key file itself is faked; everything else keeps reading from disk,
// because app-config and its dependants load in the same import.
const stubKeyFileRead = (impl: () => string) => {
vi.spyOn(fs, 'readFileSync').mockImplementation(((file: fs.PathOrFileDescriptor, ...rest: unknown[]) => {
if (String(file) === ENC_KEY_FILE) return impl();
if (String(file) === JWT_SECRET_FILE) throw enoent();
return (realRead as (...args: unknown[]) => unknown)(file, ...rest);
}) as typeof fs.readFileSync);
};
it('CFGKEY-001: aborts boot when the key file cannot be read for a reason other than ENOENT', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
const write = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
stubKeyFileRead(() => {
const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
await expect(import('../../src/config')).rejects.toThrow('process.exit called');
expect(exit).toHaveBeenCalledWith(1);
expect(write).not.toHaveBeenCalledWith(ENC_KEY_FILE, expect.anything(), expect.anything());
});
it('CFGKEY-002: refuses to regenerate over an existing but empty key file', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
const write = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.spyOn(fs, 'existsSync').mockImplementation(((file: fs.PathLike) => String(file) === ENC_KEY_FILE) as typeof fs.existsSync);
stubKeyFileRead(() => ' \n');
await expect(import('../../src/config')).rejects.toThrow('process.exit called');
expect(exit).toHaveBeenCalledWith(1);
expect(write).not.toHaveBeenCalledWith(ENC_KEY_FILE, expect.anything(), expect.anything());
});
it('CFGKEY-003: still auto-generates a key when nothing is on disk yet', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
vi.spyOn(console, 'log').mockImplementation(() => undefined);
// The key file is genuinely absent, so the empty-file guard must not fire.
vi.spyOn(fs, 'existsSync').mockImplementation(((file: fs.PathLike) => String(file) !== ENC_KEY_FILE) as typeof fs.existsSync);
stubKeyFileRead(() => {
throw enoent();
});
const config = await import('../../src/config');
expect(exit).not.toHaveBeenCalled();
expect(config.ENCRYPTION_KEY).toMatch(/^[0-9a-f]{64}$/);
});
});