Files
OpenFrame/tests/api/auth-matrix.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

1004 lines
41 KiB
TypeScript

// A sweep over every route module under app/api asserting that an
// unauthenticated caller can never reach a 2xx.
//
// Three properties make this more than a smoke test:
//
// 1. The routes are enumerated by walking app/api on disk and cross-checked
// against the table below. Add a route and this file fails until someone
// classifies it as guarded or public. That is the point: the classification
// is a reviewable diff, not an omission nobody notices.
//
// 2. Every id in the table is a real row, seeded per test. A matrix built on
// made-up ids passes even with the authorization deleted, because the route
// 404s before it ever checks anything. Here the project exists, the video
// exists, the comment exists, and the only reason the call fails is the
// access check.
//
// 3. A 500 counts as a failure. Rejecting an anonymous caller by crashing is
// not rejecting it.
//
// The project is PRIVATE and no share-session cookie is sent, so nothing here
// is legitimately reachable without a session.
import fs from 'node:fs';
import path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { REPO_ROOT } from '../helpers/env';
import { apiRequest, callRoute, readData, type RouteHandler } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createApprovalRequest,
createComment,
createCommentTag,
createProject,
createShareLink,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
createInvitation,
} from '../factories';
import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route';
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
import * as billingPortalRoute from '@/app/api/billing/portal/route';
import * as billingRoute from '@/app/api/billing/route';
import * as commentRoute from '@/app/api/comments/[commentId]/route';
import * as feedbackRoute from '@/app/api/feedback/route';
import * as feedbackUploadRoute from '@/app/api/feedback/upload/route';
import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route';
import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route';
import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route';
import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route';
import * as projectMemberRoute from '@/app/api/projects/[projectId]/members/[memberId]/route';
import * as projectMembersRoute from '@/app/api/projects/[projectId]/members/route';
import * as projectRoute from '@/app/api/projects/[projectId]/route';
import * as projectTagsRoute from '@/app/api/projects/[projectId]/tags/route';
import * as projectTagRoute from '@/app/api/projects/[projectId]/tags/[tagId]/route';
import * as videosBulkDeleteRoute from '@/app/api/projects/[projectId]/videos/bulk-delete/route';
import * as videosBunnyInitRoute from '@/app/api/projects/[projectId]/videos/bunny-init/route';
import * as videosMoveRoute from '@/app/api/projects/[projectId]/videos/move/route';
import * as videosR2CompleteRoute from '@/app/api/projects/[projectId]/videos/r2-complete/route';
import * as videosR2InitRoute from '@/app/api/projects/[projectId]/videos/r2-init/route';
import * as projectVideosRoute from '@/app/api/projects/[projectId]/videos/route';
import * as projectVideoRoute from '@/app/api/projects/[projectId]/videos/[videoId]/route';
import * as videoShareRoute from '@/app/api/projects/[projectId]/videos/[videoId]/share/route';
import * as videoVersionsRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/route';
import * as videoVersionRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route';
import * as projectsRoute from '@/app/api/projects/route';
import * as searchRoute from '@/app/api/search/route';
import * as settingsNotificationsRoute from '@/app/api/settings/notifications/route';
import * as settingsStorageRoute from '@/app/api/settings/storage/route';
import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route';
import * as uploadAudioRoute from '@/app/api/upload/audio/route';
import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
import * as uploadImageRoute from '@/app/api/upload/image/route';
import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route';
import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route';
import * as versionCommentsRoute from '@/app/api/versions/[versionId]/comments/route';
import * as versionDownloadRoute from '@/app/api/versions/[versionId]/download/route';
import * as assetDownloadRoute from '@/app/api/videos/[videoId]/assets/[assetId]/download/route';
import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route';
import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route';
import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route';
import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route';
import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route';
import * as watchRoute from '@/app/api/watch/[videoId]/route';
import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route';
import * as workspacesRoute from '@/app/api/workspaces/route';
import * as workspaceInvitationRoute from '@/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route';
import * as workspaceMemberRoute from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
import * as workspaceMembersRoute from '@/app/api/workspaces/[workspaceId]/members/route';
import * as workspaceRoute from '@/app/api/workspaces/[workspaceId]/route';
// ---------------------------------------------------------------------------
// R2 boundary
// ---------------------------------------------------------------------------
// Only the admin half of this file needs it: POST /api/admin/stats/refresh-r2
// walks the whole bucket through `r2Client`, which tests/setup/api.ts leaves
// real because it only stubs the named helpers in `@/lib/r2`. The recorder below
// is the same seam tests/api/lib-admin-stats.test.ts and
// tests/api/lib-r2-cleanup.test.ts use, and it doubles as the proof that the
// route ran its body rather than merely getting past the guard.
//
// Registering `@/lib/r2` here replaces the setup file's registration for that
// module, so the presigners are the real ones for the rest of this file. That is
// safe precisely because of what this suite asserts: no anonymous caller reaches
// a line that presigns anything, they all stop at 401 or 403.
//
// vi.mock factories are hoisted above every const in the file, so the recorder
// has to be hoisted with them.
const r2 = vi.hoisted(() => ({
bucket: 'openframe-auth-matrix-test-bucket',
/** Buckets handed to ListObjectsV2, in call order. */
listedBuckets: [] as string[],
}));
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
return {
...actual,
R2_BUCKET_NAME: r2.bucket,
r2Client: {
send: async (command: { input?: { Bucket?: string } }) => {
r2.listedBuckets.push(command.input?.Bucket ?? '');
return {
Contents: [{ Key: 'videos/auth-matrix-fixture.mp4', Size: 2048 }],
IsTruncated: false,
};
},
},
};
});
// ---------------------------------------------------------------------------
// The count guard
// ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
const EXPECTED_ROUTE_MODULE_COUNT = 60;
/**
* Routes that are public by design, and why. Everything else must reject an
* anonymous caller. Moving a file into this set is the visible diff that says
* "this endpoint is now reachable without a session".
*/
const PUBLIC_ROUTES: ReadonlyMap<string, string> = new Map([
[
'auth/[...nextauth]/route.ts',
// The NextAuth handler itself: sign-in, callback and CSRF endpoints. It has
// to be reachable by a caller who has no session yet, by definition.
'NextAuth sign-in/callback handler',
],
[
'auth/register/route.ts',
// Account creation. Gated by OPENFRAME_REQUIRE_INVITE_CODE plus an IP rate
// limit rather than by a session. Covered in tests/api/register.test.ts.
'account creation, gated by the invite code',
],
[
'auth/verify-email/route.ts',
// Reached by clicking a link in an email, before the user can sign in.
// Authenticated by the one-time token in the query string.
'email verification link, authenticated by a single-use token',
],
[
'auth/verify-email/resend/route.ts',
// A user who cannot sign in because they are unverified has no session to
// present. Rate limited by IP, and answers identically for unknown emails
// so it cannot be used to enumerate accounts.
'resend of the verification email, for users who cannot sign in yet',
],
[
'stripe/webhook/route.ts',
// Called by Stripe, not by a browser. Authenticated by the HMAC signature
// in the stripe-signature header. Covered in
// tests/api/stripe-webhook.test.ts, including the rejection of a bad one.
'Stripe webhook, authenticated by an HMAC signature',
],
]);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png';
const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
interface Fixtures {
userId: string;
workspaceId: string;
workspaceMemberId: string;
workspaceInvitationId: string;
projectId: string;
projectMemberId: string;
projectInvitationId: string;
tagId: string;
videoId: string;
versionId: string;
commentId: string;
assetId: string;
approvalRequestId: string;
feedbackId: string;
}
async function seedFixtures(): Promise<Fixtures> {
const owner = await createUser();
const collaborator = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const workspaceMember = await addWorkspaceMember({
workspaceId: workspace.id,
userId: collaborator.id,
});
const workspaceInvitation = await createInvitation({
invitedById: owner.id,
scope: 'WORKSPACE',
workspaceId: workspace.id,
});
// PRIVATE on purpose. A PUBLIC project grants anonymous read access through
// computeProjectAccess(), which would make several of the GET routes return
// 200 for entirely legitimate reasons and hide the ones that should not.
const project = await createProject({
ownerId: owner.id,
workspaceId: workspace.id,
visibility: 'PRIVATE',
allowDownloads: true,
});
const projectMember = await addProjectMember({
projectId: project.id,
userId: collaborator.id,
});
const projectInvitation = await createInvitation({
invitedById: owner.id,
scope: 'PROJECT',
projectId: project.id,
});
const tag = await createCommentTag({ projectId: project.id });
const video = await createVideo({ projectId: project.id });
const version = await createVersion({
videoParentId: video.id,
providerId: 'r2',
providerVideoId: `videos/${VIDEO_FILENAME}`,
originalUrl: `/api/upload/video/${VIDEO_FILENAME}`,
sizeBytes: BigInt(1024),
});
const comment = await createComment({ versionId: version.id, authorId: owner.id });
const asset = await createVideoAsset({
videoId: video.id,
billedUserId: owner.id,
sourceUrl: `/api/upload/image/${IMAGE_FILENAME}`,
});
// A second asset so /api/upload/audio/[filename] resolves to a real row too.
await createVideoAsset({
videoId: video.id,
billedUserId: owner.id,
kind: 'AUDIO',
provider: 'R2_AUDIO',
sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`,
});
await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' });
const approvalRequest = await createApprovalRequest({
versionId: version.id,
requestedById: owner.id,
approverIds: [collaborator.id],
});
const feedback = await db.userFeedback.create({
data: {
userId: owner.id,
type: 'FEEDBACK',
title: 'Matrix fixture feedback',
message: 'Seeded so the admin delete route has a real row to refuse.',
},
});
return {
userId: owner.id,
workspaceId: workspace.id,
workspaceMemberId: workspaceMember.id,
workspaceInvitationId: workspaceInvitation.id,
projectId: project.id,
projectMemberId: projectMember.id,
projectInvitationId: projectInvitation.id,
tagId: tag.id,
videoId: video.id,
versionId: version.id,
commentId: comment.id,
assetId: asset.id,
approvalRequestId: approvalRequest.id,
feedbackId: feedback.id,
};
}
// ---------------------------------------------------------------------------
// The table
// ---------------------------------------------------------------------------
type ParamRecord = Record<string, string | string[]>;
interface RouteCase {
/** Path of the route module relative to app/api. */
file: string;
module: Record<string, unknown>;
url: (fixtures: Fixtures) => string;
params?: (fixtures: Fixtures) => ParamRecord;
/** JSON body for the non-GET methods. A valid `{}` by default, so that a
* route which parses before authorizing rejects rather than crashes. */
body?: unknown;
/** Replaces `body`, for the routes that read request.formData(). */
rawBody?: (fixtures: Fixtures) => BodyInit;
headers?: Record<string, string>;
}
/**
* A multipart body that gets past the shape checks in the two upload routes and
* reaches their access check.
*
* This is not decoration. Both routes validate the request before they
* authorize: /api/upload/image bails with "Missing Content-Length header" at its
* first line, and /api/upload/audio bails with "No audio file provided" before
* checkProjectAccess() is ever called. An empty FormData therefore produced a
* 400 for an anonymous caller *and* an identical 400 for the workspace owner,
* which means the assertion below held with the authorization deleted. Sending a
* real file and a real videoId is what makes the 403 come from the access check.
*/
function uploadForm(field: 'image' | 'audio', fixtures: Fixtures): FormData {
const form = new FormData();
form.append(field, new File([new Uint8Array([1, 2, 3, 4])], `anon.${field}`));
form.append('videoId', fixtures.videoId);
return form;
}
const ROUTE_CASES: readonly RouteCase[] = [
{
file: 'admin/feedback/[feedbackId]/route.ts',
module: adminFeedbackRoute,
url: (f) => `/api/admin/feedback/${f.feedbackId}`,
params: (f) => ({ feedbackId: f.feedbackId }),
},
{
file: 'admin/stats/refresh-r2/route.ts',
module: adminRefreshR2Route,
url: () => '/api/admin/stats/refresh-r2',
},
{
file: 'approvals/[requestId]/cancel/route.ts',
module: approvalCancelRoute,
url: (f) => `/api/approvals/${f.approvalRequestId}/cancel`,
params: (f) => ({ requestId: f.approvalRequestId }),
},
{
file: 'approvals/[requestId]/decision/route.ts',
module: approvalDecisionRoute,
url: (f) => `/api/approvals/${f.approvalRequestId}/decision`,
params: (f) => ({ requestId: f.approvalRequestId }),
body: { decision: 'APPROVED' },
},
{
file: 'billing/checkout/route.ts',
module: billingCheckoutRoute,
url: () => '/api/billing/checkout',
headers: { origin: 'http://localhost:3000' },
},
{
file: 'billing/portal/route.ts',
module: billingPortalRoute,
url: () => '/api/billing/portal',
headers: { origin: 'http://localhost:3000' },
},
{ file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' },
{
file: 'comments/[commentId]/route.ts',
module: commentRoute,
url: (f) => `/api/comments/${f.commentId}`,
params: (f) => ({ commentId: f.commentId }),
body: { content: 'edited by an anonymous caller' },
},
{
file: 'feedback/route.ts',
module: feedbackRoute,
url: () => '/api/feedback',
body: { type: 'FEEDBACK', title: 'anon', message: 'anon' },
},
{
file: 'feedback/upload/route.ts',
module: feedbackUploadRoute,
url: () => '/api/feedback/upload',
rawBody: () => new FormData(),
},
{
file: 'onboarding/complete/route.ts',
module: onboardingCompleteRoute,
url: () => '/api/onboarding/complete',
},
{
file: 'projects/[projectId]/approval-candidates/route.ts',
module: approvalCandidatesRoute,
url: (f) => `/api/projects/${f.projectId}/approval-candidates`,
params: (f) => ({ projectId: f.projectId }),
},
{
file: 'projects/[projectId]/download/route.ts',
module: projectDownloadRoute,
url: (f) => `/api/projects/${f.projectId}/download`,
params: (f) => ({ projectId: f.projectId }),
},
{
file: 'projects/[projectId]/members/invitations/[invitationId]/route.ts',
module: projectInvitationRoute,
url: (f) => `/api/projects/${f.projectId}/members/invitations/${f.projectInvitationId}`,
params: (f) => ({ projectId: f.projectId, invitationId: f.projectInvitationId }),
},
{
file: 'projects/[projectId]/members/[memberId]/route.ts',
module: projectMemberRoute,
url: (f) => `/api/projects/${f.projectId}/members/${f.projectMemberId}`,
params: (f) => ({ projectId: f.projectId, memberId: f.projectMemberId }),
body: { role: 'ADMIN' },
},
{
file: 'projects/[projectId]/members/route.ts',
module: projectMembersRoute,
url: (f) => `/api/projects/${f.projectId}/members`,
params: (f) => ({ projectId: f.projectId }),
body: { email: '[email protected]', role: 'ADMIN' },
},
{
file: 'projects/[projectId]/route.ts',
module: projectRoute,
url: (f) => `/api/projects/${f.projectId}`,
params: (f) => ({ projectId: f.projectId }),
body: { name: 'renamed by an anonymous caller' },
},
{
file: 'projects/[projectId]/tags/route.ts',
module: projectTagsRoute,
url: (f) => `/api/projects/${f.projectId}/tags`,
params: (f) => ({ projectId: f.projectId }),
body: { name: 'Anon', color: '#ff0000' },
},
{
file: 'projects/[projectId]/tags/[tagId]/route.ts',
module: projectTagRoute,
url: (f) => `/api/projects/${f.projectId}/tags/${f.tagId}`,
params: (f) => ({ projectId: f.projectId, tagId: f.tagId }),
body: { name: 'Anon' },
},
{
file: 'projects/[projectId]/videos/bulk-delete/route.ts',
module: videosBulkDeleteRoute,
url: (f) => `/api/projects/${f.projectId}/videos/bulk-delete`,
params: (f) => ({ projectId: f.projectId }),
body: { videoIds: ['does-not-matter'] },
},
{
file: 'projects/[projectId]/videos/bunny-init/route.ts',
module: videosBunnyInitRoute,
url: (f) => `/api/projects/${f.projectId}/videos/bunny-init`,
params: (f) => ({ projectId: f.projectId }),
body: { title: 'anon' },
},
{
file: 'projects/[projectId]/videos/move/route.ts',
module: videosMoveRoute,
url: (f) => `/api/projects/${f.projectId}/videos/move`,
params: (f) => ({ projectId: f.projectId }),
body: { videoIds: ['x'], targetProjectId: 'y' },
},
{
file: 'projects/[projectId]/videos/r2-complete/route.ts',
module: videosR2CompleteRoute,
url: (f) => `/api/projects/${f.projectId}/videos/r2-complete`,
params: (f) => ({ projectId: f.projectId }),
body: { objectKey: 'x', uploadToken: 'y' },
},
{
file: 'projects/[projectId]/videos/r2-init/route.ts',
module: videosR2InitRoute,
url: (f) => `/api/projects/${f.projectId}/videos/r2-init`,
params: (f) => ({ projectId: f.projectId }),
body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
},
{
file: 'projects/[projectId]/videos/route.ts',
module: projectVideosRoute,
url: (f) => `/api/projects/${f.projectId}/videos`,
params: (f) => ({ projectId: f.projectId }),
body: { title: 'anon', videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
},
{
file: 'projects/[projectId]/videos/[videoId]/route.ts',
module: projectVideoRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
body: { title: 'renamed by an anonymous caller' },
},
{
file: 'projects/[projectId]/videos/[videoId]/share/route.ts',
module: videoShareRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/share`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
body: { allowGuests: true },
},
{
file: 'projects/[projectId]/videos/[videoId]/versions/route.ts',
module: videoVersionsRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
body: { videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
},
{
file: 'projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts',
module: videoVersionRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions/${f.versionId}`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId, versionId: f.versionId }),
body: { versionLabel: 'anon' },
},
{
file: 'projects/route.ts',
module: projectsRoute,
url: () => '/api/projects',
body: { name: 'anon project', workspaceId: 'anything' },
},
{ file: 'search/route.ts', module: searchRoute, url: () => '/api/search?q=test' },
{
file: 'settings/notifications/route.ts',
module: settingsNotificationsRoute,
url: () => '/api/settings/notifications',
body: { emailEnabled: true },
},
{
file: 'settings/storage/route.ts',
module: settingsStorageRoute,
url: () => '/api/settings/storage',
},
{
file: 'upload/audio/[filename]/route.ts',
module: uploadAudioFileRoute,
url: () => `/api/upload/audio/${AUDIO_FILENAME}`,
params: () => ({ filename: AUDIO_FILENAME }),
},
{
file: 'upload/audio/route.ts',
module: uploadAudioRoute,
url: () => '/api/upload/audio',
rawBody: (f) => uploadForm('audio', f),
},
{
file: 'upload/image/[filename]/route.ts',
module: uploadImageFileRoute,
url: () => `/api/upload/image/${IMAGE_FILENAME}`,
params: () => ({ filename: IMAGE_FILENAME }),
},
{
file: 'upload/image/route.ts',
module: uploadImageRoute,
url: () => '/api/upload/image',
rawBody: (f) => uploadForm('image', f),
// The route rejects a missing Content-Length before it does anything else,
// and constructing a Request from a FormData does not set one.
headers: { 'content-length': '2048' },
},
{
file: 'upload/video/[filename]/route.ts',
module: uploadVideoFileRoute,
url: () => `/api/upload/video/${VIDEO_FILENAME}`,
params: () => ({ filename: VIDEO_FILENAME }),
},
{
file: 'versions/[versionId]/approvals/route.ts',
module: versionApprovalsRoute,
url: (f) => `/api/versions/${f.versionId}/approvals`,
params: (f) => ({ versionId: f.versionId }),
body: { approverIds: ['someone'] },
},
{
file: 'versions/[versionId]/comments/export/route.ts',
module: commentsExportRoute,
url: (f) => `/api/versions/${f.versionId}/comments/export`,
params: (f) => ({ versionId: f.versionId }),
},
{
file: 'versions/[versionId]/comments/route.ts',
module: versionCommentsRoute,
url: (f) => `/api/versions/${f.versionId}/comments`,
params: (f) => ({ versionId: f.versionId }),
body: { content: 'anonymous comment', timestamp: 1, guestName: 'Anon' },
},
{
file: 'versions/[versionId]/download/route.ts',
module: versionDownloadRoute,
url: (f) => `/api/versions/${f.versionId}/download`,
params: (f) => ({ versionId: f.versionId }),
},
{
file: 'videos/[videoId]/assets/[assetId]/download/route.ts',
module: assetDownloadRoute,
url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}/download`,
params: (f) => ({ videoId: f.videoId, assetId: f.assetId }),
},
{
file: 'videos/[videoId]/assets/[assetId]/route.ts',
module: assetRoute,
url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}`,
params: (f) => ({ videoId: f.videoId, assetId: f.assetId }),
},
{
file: 'videos/[videoId]/assets/bunny-init/route.ts',
module: assetsBunnyInitRoute,
url: (f) => `/api/videos/${f.videoId}/assets/bunny-init`,
params: (f) => ({ videoId: f.videoId }),
// Bunny uploads are unconfigured in the test environment, so this body
// reaches the access check and nothing beyond it. The exact-status coverage
// is in tests/api/assets-authz.test.ts, which asserts the 403 for a stranger
// next to the 400 a member gets one line below the guard.
body: { fileName: 'a.mp4' },
},
{
file: 'videos/[videoId]/assets/r2-init/route.ts',
module: assetsR2InitRoute,
url: (f) => `/api/videos/${f.videoId}/assets/r2-init`,
params: (f) => ({ videoId: f.videoId }),
body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
},
{
file: 'videos/[videoId]/assets/route.ts',
module: assetsRoute,
url: (f) => `/api/videos/${f.videoId}/assets`,
params: (f) => ({ videoId: f.videoId }),
// The body deliberately carries no `provider`. Every provider that could
// reach 201 needs a live R2 or YouTube call, so the request is built to stop
// at the access check: POST answers 403 there, and would answer 400
// "Invalid provider" one line below if the guard were gone. The
// exact-status coverage lives in tests/api/assets-authz.test.ts.
body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` },
},
{
file: 'watch/[videoId]/progress/route.ts',
module: watchProgressRoute,
url: (f) => `/api/watch/${f.videoId}/progress`,
params: (f) => ({ videoId: f.videoId }),
body: { progress: 10, duration: 100 },
},
{
file: 'watch/[videoId]/route.ts',
module: watchRoute,
url: (f) => `/api/watch/${f.videoId}`,
params: (f) => ({ videoId: f.videoId }),
},
{
file: 'watch/[videoId]/upload-token/route.ts',
module: watchUploadTokenRoute,
url: (f) => `/api/watch/${f.videoId}/upload-token`,
params: (f) => ({ videoId: f.videoId }),
body: { intent: 'image' },
headers: { origin: 'http://localhost:3000' },
},
{
file: 'workspaces/route.ts',
module: workspacesRoute,
url: () => '/api/workspaces',
body: { name: 'anon workspace' },
},
{
file: 'workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts',
module: workspaceInvitationRoute,
url: (f) => `/api/workspaces/${f.workspaceId}/members/invitations/${f.workspaceInvitationId}`,
params: (f) => ({ workspaceId: f.workspaceId, invitationId: f.workspaceInvitationId }),
},
{
file: 'workspaces/[workspaceId]/members/[memberId]/route.ts',
module: workspaceMemberRoute,
url: (f) => `/api/workspaces/${f.workspaceId}/members/${f.workspaceMemberId}`,
params: (f) => ({ workspaceId: f.workspaceId, memberId: f.workspaceMemberId }),
body: { role: 'ADMIN' },
},
{
file: 'workspaces/[workspaceId]/members/route.ts',
module: workspaceMembersRoute,
url: (f) => `/api/workspaces/${f.workspaceId}/members`,
params: (f) => ({ workspaceId: f.workspaceId }),
body: { email: '[email protected]', role: 'ADMIN' },
},
{
file: 'workspaces/[workspaceId]/route.ts',
module: workspaceRoute,
url: (f) => `/api/workspaces/${f.workspaceId}`,
params: (f) => ({ workspaceId: f.workspaceId }),
body: { name: 'renamed by an anonymous caller' },
},
];
const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
/**
* The statuses a route reaches by way of its authorization check.
*
* 404 used to be in here and was taken out. Every one of the 55 guarded entries
* was instrumented and logged: all of them answer 401 or 403, none answers 404,
* so the arm was unreachable. Leaving it in was the last way an entry could pass
* without touching the guard it exists to protect. A fixture id that stops
* resolving for one route (a renamed relation, a factory that no longer writes
* the row) makes that route 404 *before* the access check, and with 404 accepted
* the entry would stay green forever while covering nothing. Now it fails and
* says so.
*/
const AUTHORIZATION_REFUSAL_STATUSES = new Set([401, 403]);
/**
* Entries that answer an anonymous caller with something other than an
* authorization refusal, each with the reason and with where the route is
* really covered. Empty today, and the intent is that it stays that way.
*
* This map and the check that consults it are the mechanised form of a lesson
* this suite learned the hard way. Asserting only "not 2xx" is too weak: a
* route that refuses a malformed request one line below its access check
* satisfies it whether or not the check is there, so the entry proves nothing.
* Two entries here had exactly that shape and were confirmed by replacing their
* `if (!context.canUploadAssets)` with `if (false)` and watching the test stay
* green on the 400 from the line below.
*
* Requiring an authorization status instead of merely a non-2xx one fixes both
* of them without touching the request they send: an anonymous caller reaches
* the guard and gets 403, and with the guard removed the 400 from the next line
* now fails the assertion instead of passing it.
*
* The map remains as a drift guard, in the same spirit as REVIEWED_MIGRATIONS
* in tests/setup/db-global.ts. Add a route that refuses before its access
* check and this suite fails until somebody decides whether the request can be
* fixed to reach the guard (which is what happened for upload/image and
* upload/audio, both of which now send a real multipart body) or whether the
* route needs a suite of its own. It fails in the other direction too: fix an
* entry and the suite tells you to delete it, so nothing here can rot into a
* permanent exemption.
*/
const NON_AUTHORIZATION_REFUSALS = new Map<string, string>();
/**
* Entries whose guard hides the existence of the row instead of refusing, so
* 404 *is* the authorization answer. Keyed the same way as
* NON_AUTHORIZATION_REFUSALS, and empty today because no route in this repo
* does that.
*
* It exists because the 404 arm was taken out of
* AUTHORIZATION_REFUSAL_STATUSES above, and a route that legitimately answers
* "no such thing" to a caller who may not know it exists is a real design, not
* a mistake. Listing it here keeps the decision visible per method rather than
* granting every entry a blanket 404 pass.
*
* Like its neighbour it fails in both directions. A route that 404s without an
* entry fails and points here; an entry whose route now answers 401 or 403
* fails and tells you to delete it, so nothing can rot into a permanent
* exemption.
*/
const NOT_FOUND_IS_THE_GUARD = new Map<string, string>();
function discoverRouteModules(): string[] {
const apiDir = path.join(REPO_ROOT, 'app', 'api');
const found: string[] = [];
const walk = (dir: string): void => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolute);
} else if (entry.name === 'route.ts') {
found.push(path.relative(apiDir, absolute));
}
}
};
walk(apiDir);
return found.sort();
}
describe('auth matrix', () => {
const discovered = discoverRouteModules();
it('classifies every route module that exists on disk', () => {
const classified = new Set<string>([
...ROUTE_CASES.map((entry) => entry.file),
...PUBLIC_ROUTES.keys(),
]);
const unclassified = discovered.filter((file) => !classified.has(file));
const stale = [...classified].filter((file) => !discovered.includes(file)).sort();
// The failure message is the whole value of this assertion: whoever added
// the route needs to know what to do about it.
expect(
{ unclassified, stale },
'A route module under app/api is missing from tests/api/auth-matrix.test.ts. ' +
'Add it to ROUTE_CASES (the normal case: it requires a session), or to ' +
'PUBLIC_ROUTES with a comment saying why anonymous access is intended.'
).toEqual({ unclassified: [], stale: [] });
});
it('still has exactly the expected number of route modules', () => {
expect(discovered).toHaveLength(EXPECTED_ROUTE_MODULE_COUNT);
expect(ROUTE_CASES.length + PUBLIC_ROUTES.size).toBe(EXPECTED_ROUTE_MODULE_COUNT);
});
it('exports at least one HTTP method from every guarded route module', () => {
const withoutHandlers = ROUTE_CASES.filter(
(entry) => !HTTP_METHODS.some((method) => typeof entry.module[method] === 'function')
).map((entry) => entry.file);
expect(withoutHandlers).toEqual([]);
});
describe('unauthenticated callers', () => {
let fixtures: Fixtures;
beforeEach(async () => {
signedOut();
fixtures = await seedFixtures();
});
for (const entry of ROUTE_CASES) {
it(`never returns 2xx for ${entry.file}`, async () => {
const methods = HTTP_METHODS.filter((method) => typeof entry.module[method] === 'function');
expect(methods.length).toBeGreaterThan(0);
const observed: Record<string, number> = {};
for (const method of methods) {
const handler = entry.module[method] as RouteHandler<ParamRecord>;
const sendsBody = method !== 'GET' && method !== 'HEAD';
const request = apiRequest(entry.url(fixtures), {
method,
headers: entry.headers,
...(sendsBody
? entry.rawBody
? { rawBody: entry.rawBody(fixtures) }
: { body: entry.body ?? {} }
: {}),
});
const response = await callRoute(handler, request, entry.params?.(fixtures) ?? {});
observed[method] = response.status;
}
for (const [method, status] of Object.entries(observed)) {
expect(
status >= 200 && status < 300,
`${method} ${entry.file} returned ${status} to an anonymous caller`
).toBe(false);
// A crash is not a rejection. If this trips, the route threw on the
// way to its access check instead of refusing cleanly.
expect(status, `${method} ${entry.file} crashed instead of refusing`).not.toBe(500);
// And a validation refusal is not a rejection either. See
// NON_AUTHORIZATION_REFUSALS for why this is worth asserting.
const key = `${method} ${entry.file}`;
const documentedReason = NON_AUTHORIZATION_REFUSALS.get(key);
const hidesExistence = NOT_FOUND_IS_THE_GUARD.get(key);
if (hidesExistence !== undefined) {
expect(
status,
`${key} is listed in NOT_FOUND_IS_THE_GUARD, which says it hides the row's ` +
`existence rather than refusing, but it answered ${status}. If it now ` +
`refuses with 401 or 403, delete its entry.`
).toBe(404);
} else if (documentedReason === undefined) {
expect(
AUTHORIZATION_REFUSAL_STATUSES.has(status),
`${key} answered ${status} to an anonymous caller, which is not an ` +
`authorization refusal. The route rejected the request before it reached ` +
`its access check, so this entry passes whether or not the guard exists. ` +
`Fix the request this entry sends so it reaches the guard, or add the ` +
`entry to NON_AUTHORIZATION_REFUSALS with the suite that covers it ` +
`properly. A 404 means either the fixture id no longer resolves, which is ` +
`the same bug wearing a different status, or the route hides existence on ` +
`purpose, in which case it belongs in NOT_FOUND_IS_THE_GUARD.`
).toBe(true);
} else {
expect(
AUTHORIZATION_REFUSAL_STATUSES.has(status),
`${key} now answers ${status}, which is an authorization refusal, so it no ` +
`longer belongs in NON_AUTHORIZATION_REFUSALS. Delete its entry.`
).toBe(false);
}
}
});
}
});
it('documents a reason for every public route, and each one still exists', () => {
for (const [file, reason] of PUBLIC_ROUTES) {
expect(reason.length, `${file} needs a reason`).toBeGreaterThan(10);
expect(fs.existsSync(path.join(REPO_ROOT, 'app', 'api', file))).toBe(true);
}
});
// -------------------------------------------------------------------------
// Signed in: admin against non-admin
// -------------------------------------------------------------------------
// The sweep above only proves that app/api/admin/** refuses a caller with no
// session, and `!session?.user?.isAdmin` is true for a null session for the
// wrong reason. Nothing else in the suite touches `isAdmin` at all, so
// rewriting that guard as `!session?.user?.id` would leave every one of those
// tests green while handing the admin endpoints to any signed-in user. The
// refusals below are what separate "no session" from "not an admin".
//
// Each refusal is paired with the admin who must get through, because a
// refusal on its own is only half a guard. Replacing the whole check in
// app/api/admin/stats/refresh-r2/route.ts with an unconditional
// `return apiErrors.forbidden(...)`, which locks every admin out of the
// endpoint permanently, left all 984 api tests green until these two pairs
// existed. tests/e2e/admin.spec.ts does not close it either: it only POSTs as
// a non-admin.
//
// `isAdmin` is not a column. lib/auth.ts derives it in the jwt callback from
// the ADMIN_EMAILS environment variable and the session callback copies it
// onto session.user. The api project mocks `auth()` itself, so neither
// callback runs and stubbing ADMIN_EMAILS here would change nothing; the
// session signedInAs() builds is that derivation's output, which is all a
// route ever sees. The derivation itself is covered end to end by
// tests/e2e/admin.spec.ts.
describe('admin routes', () => {
let fixtures: Fixtures;
beforeEach(async () => {
r2.listedBuckets.length = 0;
fixtures = await seedFixtures();
});
it('refuses DELETE /api/admin/feedback/[feedbackId] to a non-admin and keeps the row', async () => {
signedInAs({ id: fixtures.userId, isAdmin: false });
const response = await callRoute(
adminFeedbackRoute.DELETE as unknown as RouteHandler<ParamRecord>,
apiRequest(`/api/admin/feedback/${fixtures.feedbackId}`, { method: 'DELETE' }),
{ feedbackId: fixtures.feedbackId }
);
expect(response.status).toBe(403);
expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(1);
});
it('lets an admin DELETE /api/admin/feedback/[feedbackId], and the row is gone', async () => {
signedInAs({ id: fixtures.userId, isAdmin: true });
const response = await callRoute(
adminFeedbackRoute.DELETE as unknown as RouteHandler<ParamRecord>,
apiRequest(`/api/admin/feedback/${fixtures.feedbackId}`, { method: 'DELETE' }),
{ feedbackId: fixtures.feedbackId }
);
expect(response.status).toBe(200);
expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(0);
});
it('refuses POST /api/admin/stats/refresh-r2 to a non-admin', async () => {
signedInAs({ id: fixtures.userId, isAdmin: false });
const response = await callRoute(
adminRefreshR2Route.POST as RouteHandler<ParamRecord>,
apiRequest('/api/admin/stats/refresh-r2', { method: 'POST', body: {} })
);
expect(response.status).toBe(403);
// The refusal has to happen before the work, not after it.
expect(r2.listedBuckets).toEqual([]);
});
it('lets an admin POST /api/admin/stats/refresh-r2, and the bucket is walked', async () => {
signedInAs({ id: fixtures.userId, isAdmin: true });
const response = await callRoute(
adminRefreshR2Route.POST as RouteHandler<ParamRecord>,
apiRequest('/api/admin/stats/refresh-r2', { method: 'POST', body: {} })
);
expect(response.status).toBe(200);
const data = await readData<{ ok: boolean; refreshedAt: string }>(response);
expect(data.ok).toBe(true);
expect(Number.isNaN(Date.parse(data.refreshedAt))).toBe(false);
// Getting past the guard is not the same as doing the job. Without this,
// a handler that returned `{ ok: true }` and skipped the refresh would
// still pass.
expect(r2.listedBuckets).toEqual([r2.bucket]);
});
});
});