mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The suite that landed in #43/#44 was written against existing behaviour, so a number of tests pinned bugs rather than asserting correct behaviour. This fixes the production code and moves each of those tests onto the fixed behaviour in the same change. Security: - project-download: derive the archive entry extension from the last path segment and restrict it to a short alphanumeric run, so an extensionless allowlisted url can no longer contribute a path separator; validate the r2 branch against the strict proxy-path pattern instead of a `startsWith`, which let `/api/upload/video/clip.mp4/../../etc/passwd` through verbatim. - rate-limit: hash a key or action wider than its column instead of skipping the query. Both the guard and the failing INSERT used to answer "allowed", so the limit stopped applying entirely. Warn at startup when TRUSTED_PROXY_MODE is unset in production. - video uploads: the file name decides the content type; a client-declared video mime no longer makes `payload.exe` acceptable. - email templates: escape in the helpers rather than relying on every caller, with an explicit `rawEmailHtml()` opt-out for the one call site that builds markup. `escapeHtml` now covers the single quote. - CSP: allow loopback object storage outside production only. - route-access: reach the billing redirect only for the workspace owner. Keying it off the owner's billing status alone made the redirect target an oracle for whose subscription had lapsed, and sent members to a page they cannot act on. - search: carry the same billing condition every other read path carries. - logger: check `err.name` as well as `err.constructor.name`, so a re-thrown, deserialised or minified Prisma error is still redacted. - upload tokens: resolve the signing secret outside the try, so a server booted without one fails loudly instead of reporting every grant as a forgery. - invitations: never downgrade an existing membership, and report a scoped invitation that points at nothing as not_found rather than accepted. - auth: resolve the workspace role for every signed-in caller, so checkProjectAccess and computeProjectAccess stop disagreeing about the owner who also owns the workspace. The `intent` option is gone with it. - r2-media-proxy: validate the object key inside the proxy so the guard travels with the function; delete the unused, unanchored `mediaUrlToR2Key`. - r2: sign the content type into presigned PUT grants. Correctness: - frame rate snapping picks the nearest standard, not the first within tolerance, so 24, 30 and 60 fps are reachable at all. - a version upload registers its Bunny cleanup as soon as bunny-init answers, so a failed tus upload no longer leaves a billed video behind. - deleting videos clears storage before the rows, so a refused DELETE leaves a retryable row rather than an orphaned object. - an expired upload session can be cancelled, which is what releases its quota. - `voice/` joins the delete allowlist, so a voice note can be removed by the module that wrote it. - a failed CORS write propagates instead of being mistaken for an empty config and replacing the bucket's rules. - filtering projects by workspace no longer hides projects the unfiltered call returns. - upload retries skip aborts and permanent 4xx; progress no longer divides by zero. - reply edits no longer clear the comment's tag; optimistic resolve rolls back to the state it replaced; the delete snapshot is captured once. - assorted UI fixes: duplicate React keys, double-click guards reading stale closures, the tag list fetched twice per load, a failed member list rendering as an empty one, a stale "Initializing upload..." beside a failure, and a registration banner pointing at an email that never arrives. Consistency and access: - the two download routes answer 404 for an id belonging to another tenant, as the comment export route already did. A caller who does belong still gets 403. - accessible names for the share-link password field, the guest name gates, the version dialog inputs and the comment-tag controls. Repository health: - the runner image installs production dependencies only. - a setup file for the unit project restores stubbed env centrally. - native tsconfig path resolution replaces vite-tsconfig-paths. - `uploadBytesWithProgress` exists once. - admin stats bill Bunny storage to the workspace owner like every other quota, gate on the configured flag, wire up the single-flight guard and count the statuses that belonged to no bucket. - `r2Client.destroy()` releases the presign client too. - `prepare` tolerates a production install, where husky is absent.
1018 lines
42 KiB
TypeScript
1018 lines
42 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>([
|
|
// Both download routes take a bare resource id with no project in the path, so a 403
|
|
// for an id belonging to another tenant would confirm that the id exists. They answer
|
|
// 404 to any caller with no relationship to the project, which is what
|
|
// versions/[versionId]/comments/export has always done for the identical shape.
|
|
// Somebody who does belong, an owner whose billing lapsed for instance, still gets 403.
|
|
[
|
|
'GET versions/[versionId]/download/route.ts',
|
|
'hides whether the version id exists from a caller with no relationship to it',
|
|
],
|
|
[
|
|
'GET videos/[videoId]/assets/[assetId]/download/route.ts',
|
|
'hides whether the video id exists from a caller with no relationship to it',
|
|
],
|
|
]);
|
|
|
|
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]);
|
|
});
|
|
});
|
|
});
|