Files
OpenFrame/tests/unit/lib/logger.test.ts
T
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine
agents wrote suites in parallel against private databases, then a tenth read all
of it adversarially and five of its findings were fixed.

  unit + component  2076 -> 2079 (+888 over the round)
  api                647 -> 1015
  e2e                 18 -> 29

What was closed:

- lib/route-access.ts, the page-level authorization layer, went from zero tests
  to 48. Every API route was guarded and none of the pages were.
- The five media proxy routes now have a real 2xx beside every 403. The blocker
  was the positive control, solved by stubbing r2Client.send() and leaving
  lib/r2-media-proxy.ts itself real.
- Every remaining server-side lib module: invitations, email verification, the
  upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle,
  notifications and admin stats.
- Six video-page hooks, and the chunking arithmetic extracted out of
  lib/client/r2-video-upload.ts as a pure module.
- Five end-to-end flows: workspace members, bulk operations, the admin area,
  player interaction and failure recovery.

Three things about the harness itself turned out to be wrong:

- Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every
  route reaching finalizeR2VideoUpload silently took the "not a valid video"
  branch and no test noticed.
- The auth matrix asserted only "not 2xx", which two entries satisfied without
  their guard existing. It now requires 401 or 403, which makes both
  load-bearing, and all 60 routes pass the stricter form.
- Both admin API routes had no positive control anywhere: replacing their guard
  with an unconditional refusal left the entire suite green. Found by the
  adversarial review, now covered.

Process:

- bun run test:mutation runs StrykerJS over the authorization and validation
  modules. Diagnostic, not a gate, weekly in CI rather than on a push.
- playwright.config.ts gains an opt-in webkit project for the player spec.
- AGENTS.md now requires a batch of new tests to be reviewed by somebody who
  did not write them.

Only two production files change, both deliberate: lib/auth.ts loses a verbatim
copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the
extracted arithmetic. No behaviour change in either.
2026-07-26 13:25:11 +07:00

329 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { logError } from '@/lib/logger';
// Prisma's client errors are real classes, so `err.constructor.name` is what the
// sanitiser branches on. Reproducing them as classes rather than as plain objects
// with a `name` property is the only way to exercise the branch the way production
// reaches it.
class PrismaClientKnownRequestError extends Error {
code: string;
meta?: Record<string, unknown>;
constructor(message: string, code: string, meta?: Record<string, unknown>) {
super(message);
this.name = 'PrismaClientKnownRequestError';
this.code = code;
this.meta = meta;
}
}
class PrismaClientValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'PrismaClientValidationError';
}
}
class PrismaClientInitializationError extends Error {
errorCode: string;
constructor(message: string) {
super(message);
this.name = 'PrismaClientInitializationError';
this.errorCode = 'P1001';
}
}
// A Stripe SDK error, shaped the way the sanitiser detects it: a string `type`
// alongside a numeric `statusCode`.
class StripeCardError extends Error {
type = 'StripeCardError';
statusCode = 402;
constructor(message: string) {
super(message);
this.name = 'StripeCardError';
}
}
// The kind of message a Prisma failure actually carries: the failing statement,
// the table and column names, and the literal values from the WHERE clause. None
// of this may reach a log sink.
const LEAKY_PRISMA_MESSAGE = [
'Invalid `prisma.user.findUnique()` invocation:',
'Raw query failed. Code: `42P01`.',
'SELECT "public"."User"."id", "public"."User"."passwordHash" FROM "public"."User"',
'WHERE "public"."User"."email" = \'[email protected]\' LIMIT 1 OFFSET 0',
].join('\n');
// Swallows the output as well as capturing it, so the suite stays quiet.
function spyOnConsoleError() {
return vi.spyOn(console, 'error').mockImplementation(() => {});
}
let errorSpy: ReturnType<typeof spyOnConsoleError>;
function loggedPayload(): unknown {
expect(errorSpy).toHaveBeenCalledTimes(1);
return errorSpy.mock.calls[0]![1];
}
function loggedText(): string {
return JSON.stringify(loggedPayload() ?? null);
}
beforeEach(() => {
errorSpy = spyOnConsoleError();
});
afterEach(() => {
errorSpy.mockRestore();
});
describe('logError', () => {
describe('Prisma errors', () => {
it('redacts a Prisma message carrying raw SQL down to the error code', () => {
logError(
'user lookup failed',
new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002')
);
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'P2002',
message: 'Database error [P2002]',
});
});
it('leaks no fragment of the original SQL, table names or WHERE values', () => {
logError(
'user lookup failed',
new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002')
);
const text = loggedText();
expect(text).not.toContain('SELECT');
expect(text).not.toContain('passwordHash');
expect(text).not.toContain('[email protected]');
expect(text).not.toContain('prisma.user.findUnique');
expect(text).not.toContain('"public"."User"');
});
it('never logs the `meta` object, which repeats the offending field values', () => {
const err = new PrismaClientKnownRequestError('Unique constraint failed', 'P2002', {
target: ['email'],
value: '[email protected]',
});
logError('create failed', err);
expect(loggedText()).not.toContain('[email protected]');
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'P2002',
message: 'Database error [P2002]',
});
});
it('substitutes UNKNOWN when the Prisma error carries no code', () => {
logError('validation failed', new PrismaClientValidationError(LEAKY_PRISMA_MESSAGE));
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'UNKNOWN',
message: 'Database error [UNKNOWN]',
});
});
it('redacts a Prisma initialization error, whose message embeds the database url', () => {
const err = new PrismaClientInitializationError(
"Can't reach database server at `postgresql://admin:[email protected]:5432`"
);
logError('startup failed', err);
const text = loggedText();
expect(text).not.toContain('hunter2');
expect(text).not.toContain('db.internal');
// `errorCode`, not `code`, so the string branch does not match it.
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'UNKNOWN',
message: 'Database error [UNKNOWN]',
});
});
it('ignores a non-string Prisma code rather than logging it', () => {
const err = new PrismaClientKnownRequestError('boom', 'P2002');
(err as unknown as Record<string, unknown>).code = 2002;
logError('create failed', err);
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'UNKNOWN',
message: 'Database error [UNKNOWN]',
});
});
it('prefers the Prisma branch over the Stripe branch when an error matches both', () => {
const err = new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002');
const anyErr = err as unknown as Record<string, unknown>;
anyErr.type = 'invalid_request_error';
anyErr.statusCode = 400;
logError('ambiguous failure', err);
// If the ordering flipped, `message: err.message` would ship the SQL.
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'P2002',
message: 'Database error [P2002]',
});
});
// Documents a real limitation rather than an intended behaviour: the branch
// keys on the constructor name, so an error that only claims to be a Prisma
// error through `err.name` (a re-thrown, deserialised or minified one) falls
// through to the generic branch and its message is logged verbatim.
it('does not redact an error that is Prisma only by its `name` property', () => {
const err = new Error(LEAKY_PRISMA_MESSAGE);
err.name = 'PrismaClientKnownRequestError';
logError('user lookup failed', err);
expect(loggedPayload()).toEqual({ type: 'Error', message: LEAKY_PRISMA_MESSAGE });
});
});
describe('Stripe errors', () => {
it('keeps the message and records the http status as the code', () => {
logError('charge failed', new StripeCardError('Your card was declined.'));
expect(loggedPayload()).toEqual({
type: 'StripeCardError',
code: '402',
message: 'Your card was declined.',
});
});
it('reports the SDK `type` field rather than the class name', () => {
const err = new StripeCardError('No such customer: cus_123');
(err as unknown as Record<string, unknown>).type = 'invalid_request_error';
logError('portal failed', err);
expect(loggedPayload()).toMatchObject({ type: 'invalid_request_error', code: '402' });
});
it('falls through to the generic branch when statusCode is not numeric', () => {
const err = new StripeCardError('Your card was declined.');
(err as unknown as Record<string, unknown>).statusCode = '402';
logError('charge failed', err);
expect(loggedPayload()).toEqual({
type: 'StripeCardError',
message: 'Your card was declined.',
});
});
it('falls through to the generic branch when `type` is not a string', () => {
const err = new StripeCardError('Your card was declined.');
(err as unknown as Record<string, unknown>).type = 7;
logError('charge failed', err);
expect(loggedPayload()).toEqual({
type: 'StripeCardError',
message: 'Your card was declined.',
});
});
});
describe('plain errors', () => {
it('logs the type and message of an ordinary Error', () => {
logError('something broke', new Error('boom'));
expect(loggedPayload()).toEqual({ type: 'Error', message: 'boom' });
});
it('reports the subclass name as the type', () => {
class UploadRejectedError extends Error {}
logError('upload failed', new UploadRejectedError('too large'));
expect(loggedPayload()).toEqual({ type: 'UploadRejectedError', message: 'too large' });
});
it('never includes the stack trace, which exposes absolute server paths', () => {
const err = new Error('boom');
err.stack = 'Error: boom\n at /srv/openframe/app/api/projects/route.ts:42:11';
logError('something broke', err);
expect(loggedPayload()).not.toHaveProperty('stack');
expect(loggedText()).not.toContain('/srv/openframe');
});
it('does not include a `cause`, which can wrap the original driver error', () => {
const err = new Error('wrapped', { cause: new Error(LEAKY_PRISMA_MESSAGE) });
logError('something broke', err);
expect(loggedPayload()).toEqual({ type: 'Error', message: 'wrapped' });
expect(loggedText()).not.toContain('SELECT');
});
it('handles a TypeError thrown by the runtime itself', () => {
logError('bad access', new TypeError("Cannot read properties of undefined (reading 'id')"));
expect(loggedPayload()).toEqual({
type: 'TypeError',
message: "Cannot read properties of undefined (reading 'id')",
});
});
});
describe('non-Error values', () => {
// These were constructed by the caller, so they are already whatever the
// caller decided to expose and are passed through untouched.
it.each([
['a string', 'plain failure text'],
['a number', 42],
['a boolean', false],
['null', null],
['undefined', undefined],
])('passes %s through unchanged', (_label, value) => {
logError('context', value);
expect(loggedPayload()).toBe(value);
});
it('passes a structured object through by reference', () => {
const payload = { status: 502, provider: 'bunny' };
logError('upstream refused', payload);
expect(loggedPayload()).toBe(payload);
});
it('passes an Error-shaped plain object through, since it is not an Error instance', () => {
const payload = { name: 'PrismaClientKnownRequestError', message: LEAKY_PRISMA_MESSAGE };
logError('context', payload);
expect(loggedPayload()).toBe(payload);
});
});
it('writes to console.error with the context string first and the payload second', () => {
logError('projects.POST failed', new Error('boom'));
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0]).toHaveLength(2);
expect(errorSpy.mock.calls[0]![0]).toBe('projects.POST failed');
});
it('returns undefined rather than the sanitized payload', () => {
expect(logError('context', new Error('boom'))).toBeUndefined();
});
});