mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
126 lines
4.8 KiB
TypeScript
126 lines
4.8 KiB
TypeScript
// Guards the test harness itself. Every other file in tests/api depends on
|
|
// these four things being true, and when one of them silently stops being true
|
|
// the failures elsewhere look like product bugs.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { db } from '@/lib/db';
|
|
import { cleanupRateLimits } from '@/lib/rate-limit';
|
|
import { GET as getProjects } from '@/app/api/projects/route';
|
|
import { countRows, listResettableTables, resetDb } from '../helpers/db';
|
|
import { apiRequest, callRoute } from '../helpers/request';
|
|
import { signedInAs, signedOut } from '../helpers/session';
|
|
import {
|
|
createComment,
|
|
createCommentTag,
|
|
createShareLink,
|
|
createUser,
|
|
seedVersion,
|
|
} from '../factories';
|
|
|
|
describe('api test infrastructure', () => {
|
|
it('points at a test database and not at the dev one', async () => {
|
|
const [{ current_database: name }] = await db.$queryRaw<
|
|
Array<{ current_database: string }>
|
|
>`SELECT current_database()`;
|
|
|
|
// `openframe_test` is what everything uses by default. The optional suffix
|
|
// exists because this suite empties every table after every test, so two
|
|
// runs against one database destroy each other: writing several suites in
|
|
// parallel means giving each run its own database, created by hand in the
|
|
// same container and named `openframe_test_<something>`.
|
|
//
|
|
// The guard that matters is the one this leaves intact: the dev database is
|
|
// called `openframe`, which does not match, so a stray DATABASE_URL still
|
|
// cannot get this suite to truncate real data.
|
|
expect(name).toMatch(/^openframe_test(_[a-z0-9]+)?$/);
|
|
});
|
|
|
|
it('discovers every table from information_schema, so resetDb cannot drift', async () => {
|
|
const tables = await listResettableTables();
|
|
|
|
// Sampled across the schema rather than asserted exhaustively: a new model
|
|
// should not have to be added here, that is the whole point of reading
|
|
// information_schema.
|
|
expect(tables).toContain('users');
|
|
expect(tables).toContain('projects');
|
|
expect(tables).toContain('comments');
|
|
expect(tables).toContain('rate_limits');
|
|
expect(tables).toContain('video_upload_sessions');
|
|
expect(tables).not.toContain('_prisma_migrations');
|
|
});
|
|
|
|
it('resetDb empties tables that hold rows', async () => {
|
|
await createUser();
|
|
await createUser();
|
|
expect(await countRows('users')).toBe(2);
|
|
|
|
await resetDb();
|
|
|
|
expect(await countRows('users')).toBe(0);
|
|
});
|
|
|
|
// resetDb empties every table in one statement rather than in dependency
|
|
// order, relying on foreign-key triggers firing after the whole statement.
|
|
// This is the test that would catch that going wrong: the graph below spans
|
|
// parents and children in both alphabetical directions.
|
|
it('resetDb clears a full foreign-key graph in any direction', async () => {
|
|
const scenario = await seedVersion();
|
|
const tag = await createCommentTag({ projectId: scenario.project.id });
|
|
await createComment({
|
|
versionId: scenario.version.id,
|
|
authorId: scenario.owner.id,
|
|
tagId: tag.id,
|
|
});
|
|
await createShareLink({ projectId: scenario.project.id, videoId: scenario.video.id });
|
|
await db.$executeRaw`
|
|
INSERT INTO rate_limits (key, action, count, window_start)
|
|
VALUES ('reset-test', 'api', 1, NOW())
|
|
`;
|
|
|
|
await resetDb();
|
|
|
|
for (const table of await listResettableTables()) {
|
|
expect(await countRows(table), `${table} should be empty`).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('resetDb restarts the rate_limits sequence, standing in for RESTART IDENTITY', async () => {
|
|
await db.$executeRaw`
|
|
INSERT INTO rate_limits (key, action, count, window_start)
|
|
VALUES ('seq-test-a', 'api', 1, NOW()), ('seq-test-b', 'api', 1, NOW())
|
|
`;
|
|
expect((await db.rateLimit.findFirstOrThrow({ orderBy: { id: 'desc' } })).id).toBe(2);
|
|
|
|
await resetDb();
|
|
await db.$executeRaw`
|
|
INSERT INTO rate_limits (key, action, count, window_start)
|
|
VALUES ('seq-test-c', 'api', 1, NOW())
|
|
`;
|
|
|
|
expect((await db.rateLimit.findFirstOrThrow()).id).toBe(1);
|
|
});
|
|
|
|
it('exposes cleanup_rate_limits(), which prisma db push does not create', async () => {
|
|
await db.$executeRaw`
|
|
INSERT INTO rate_limits (key, action, count, window_start)
|
|
VALUES ('infra-test', 'api', 1, NOW() - INTERVAL '2 hours')
|
|
`;
|
|
expect(await countRows('rate_limits')).toBe(1);
|
|
|
|
await cleanupRateLimits();
|
|
|
|
expect(await countRows('rate_limits')).toBe(0);
|
|
});
|
|
|
|
it('mocks auth() while leaving the rest of @/lib/auth real', async () => {
|
|
signedOut();
|
|
const anonymous = await callRoute(getProjects, apiRequest('/api/projects'));
|
|
expect(anonymous.status).toBe(401);
|
|
|
|
const user = await createUser();
|
|
signedInAs(user);
|
|
const authenticated = await callRoute(getProjects, apiRequest('/api/projects'));
|
|
expect(authenticated.status).toBe(200);
|
|
});
|
|
});
|