mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
38f3cb3101
An adversarial review of what had already landed here turned up three that
were wrong, and they are the reason for this commit.
The CSP change removed 'unsafe-eval', which is not leftover: heic-to's
libheif build initialises embind through new Function(), and without it every
iPhone .heic upload fails in the browser. 'wasm-unsafe-eval' alone was tried
once already and was not enough (93b51a0b). Put back, with the reason written
down this time and the test pinning it rather than its absence.
The idempotency in-flight guard never fired. finalize() runs when the
handler's observable completes, but Nest writes the response - and therefore
stores the row - several microtasks later, so the waiting request always woke
up before the row existed and ran the handler a second time. It releases from
the point the row is written now; finalize is the backstop for handlers that
answer another way. The test drove the two in the opposite order from Nest,
which is why it passed either way.
And the trip-scoping check treated an id that resolves to nothing like one
belonging to someone else. accommodation_id has no foreign key, so shortening
a trip's dates leaves a booking pointing at a gap - which update() has always
healed by clearing the link, and which would have become a permanent 400.
The rest: a reply to a soft-deleted chat message is refused and the create
path blanks the quote like the list path does; the ws-token buckets are keyed
by account rather than by IP, where every user behind one NAT shared a
ceiling; the weather and vacay caps read through the stream, since a chunked
response has no content-length to check; the Google host gate takes country
domains again; abandoned response bodies are cancelled instead of left to GC;
the boot failure prints its errno; and a share link that is not yours to read
now says so instead of reporting that the journey has none.
TICKETJSON gets a real repair: the in-place GLOB fix only helps installs that
have not run it yet, so an appended migration puts back the note text the
case-insensitive LIKE cut off, for rows where what it stored is not valid
JSON. A genuine receipt always parses, so it cannot be touched.
Wiki: the audit log records the IP Express resolves after TRUST_PROXY, not
the leftmost X-Forwarded-For entry a caller can write. That is the right way
round, but it means the hop count has to be accurate - said plainly in both
places now.
110 lines
4.8 KiB
TypeScript
110 lines
4.8 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-001b: names the errno, and the bind-mount case behind EISDIR', async () => {
|
|
vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
throw new Error('process.exit called');
|
|
}) as never);
|
|
vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
stubKeyFileRead(() => {
|
|
const err = new Error('EISDIR: illegal operation on a directory, read') as NodeJS.ErrnoException;
|
|
err.code = 'EISDIR';
|
|
throw err;
|
|
});
|
|
|
|
await expect(import('../../src/config')).rejects.toThrow('process.exit called');
|
|
// Without the code the operator sees a message and no way to tell a
|
|
// permission problem from a directory in the file's place.
|
|
const lines = error.mock.calls.map((c) => c.join(' '));
|
|
expect(lines.some((l) => l.startsWith('FATAL:') && l.includes('EISDIR'))).toBe(true);
|
|
expect(lines.some((l) => l.includes('bind mount'))).toBe(true);
|
|
});
|
|
|
|
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}$/);
|
|
});
|
|
});
|