Files
OpenFrame/tests/api/media-proxy-authz.test.ts
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

886 lines
32 KiB
TypeScript

// Authorization tests for the five media routes under /api/upload.
//
// These are the routes that serve user media, and they are reachable without a
// session by design: a guest holding a share link has to be able to see the
// frame someone drew on, and a PUBLIC project has to render for a passer-by.
// That design is exactly what makes them worth testing. Every one of them runs
// `checkProjectAccess()` against the project that owns the referencing row, and
// nothing else stands between an anonymous request and somebody else's files.
//
// Before this file the only coverage was the anonymous sweep in
// tests/api/auth-matrix.test.ts. A 403 on its own proves very little here,
// because these routes have several ways to answer 400 or 500 before reaching
// the guard (an unparseable filename, a missing Content-Length, unconfigured
// object storage). Gap 4 of the test-gap inventory is exactly that failure:
// two matrix entries that passed with their authorization deleted. So every
// refusal below is paired with a genuine 2xx from the same route on the same
// seeded rows, with only the caller or the project's visibility changed.
//
// The positive control is possible because of the single mock in this file.
// `@/lib/r2` is re-mocked so `r2Client.send()` answers in-process: R2 is
// deliberately unconfigured in .env.test, and without this every authorized
// caller would land on a 500 from the storage client rather than a 200. Note
// what is *not* mocked: `lib/r2-media-proxy.ts` itself runs for real, so the
// content types, the object keys and the range handling asserted below are the
// production code paths. tests/unit/lib/r2-media-proxy.test.ts covers that
// module's own branches.
import { Readable } from 'node:stream';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import type { Project, User, Video, VideoVersion, Workspace } from '@prisma/client';
import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session';
import { POST as uploadImage } from '@/app/api/upload/image/route';
import { POST as uploadAudio } from '@/app/api/upload/audio/route';
import { GET as serveImage } from '@/app/api/upload/image/[filename]/route';
import { GET as serveAudio } from '@/app/api/upload/audio/[filename]/route';
import { GET as serveVideo } from '@/app/api/upload/video/[filename]/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createComment,
createExpiredUser,
createShareLink,
createUser,
createVersion,
createVideo,
createVideoAsset,
nextSeq,
seedProject,
} from '../factories';
const { r2Send } = vi.hoisted(() => ({ r2Send: vi.fn() }));
// The one seam. tests/setup/api.ts already stubs the presigners in this module
// but leaves `r2Client` real, and the real one throws on first use because no
// R2_* variable is set for the api project. Replacing just the client keeps
// every other export, including R2_BUCKET_NAME.
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
return { ...actual, r2Client: { send: r2Send } };
});
const STORED_BYTES = 'stored-object-bytes';
beforeEach(() => {
r2Send.mockReset();
r2Send.mockImplementation(async (command: unknown) => {
if (command instanceof PutObjectCommand) {
return { ETag: '"stored"' };
}
if (command instanceof GetObjectCommand) {
// ContentType is deliberately absent so the header on the response is the
// one the *route* derived from the file extension, which is the part these
// tests are asserting. The proxy's own content-type precedence is covered
// in tests/unit/lib/r2-media-proxy.test.ts.
const isRanged = Boolean(command.input.Range);
return {
Body: Readable.from([Buffer.from(STORED_BYTES)]),
ContentLength: STORED_BYTES.length,
ContentRange: isRanged ? `bytes 0-4/${STORED_BYTES.length}` : undefined,
};
}
throw new Error(`Unexpected R2 command in this suite: ${String(command)}`);
});
});
/** The Key of the nth object command R2 was asked for. */
function sentKey(call = 0): string {
const command = r2Send.mock.calls[call]?.[0] as GetObjectCommand | PutObjectCommand;
return String((command.input as { Key?: string }).Key);
}
// UUID-shaped names, because all three read routes gate the filename on a strict
// UUID regex before they look anything up. The sequence keeps them unique across
// a file that seeds several fixtures per test.
function uniqueFilename(extension: string): string {
return `2f4a6c8e-1b3d-4f5a-8c7e-${String(nextSeq()).padStart(12, '0')}.${extension}`;
}
interface MediaFixture {
owner: User;
workspace: Workspace;
project: Project;
video: Video;
version: VideoVersion;
/** Attached to a comment on `version`. */
imageFilename: string;
/** Attached to the same comment. */
audioFilename: string;
/** The `originalUrl` of `version`. */
videoFilename: string;
}
/**
* A project with one video, one r2 version, and one comment carrying both an
* annotation image and a voice note.
*
* All three read routes resolve their filename back to a project through a
* referencing row, so a filename with no row behind it is refused no matter who
* asks. Seeding all three at once means a single fixture serves every describe
* block below and the caller is the only thing that changes between them.
*/
async function seedMedia(
input: { visibility?: 'PRIVATE' | 'PUBLIC'; ownerUser?: User } = {}
): Promise<MediaFixture> {
const { owner, workspace, project } = await seedProject({
ownerUser: input.ownerUser,
visibility: input.visibility ?? 'PRIVATE',
});
const imageFilename = uniqueFilename('png');
const audioFilename = uniqueFilename('webm');
const videoFilename = uniqueFilename('mp4');
const video = await createVideo({ projectId: project.id, title: 'Video with media' });
const version = await createVersion({
videoParentId: video.id,
providerId: 'r2',
providerVideoId: `videos/${videoFilename}`,
originalUrl: `/api/upload/video/${videoFilename}`,
sizeBytes: BigInt(2048),
});
await createComment({
versionId: version.id,
authorId: owner.id,
imageUrl: `/api/upload/image/${imageFilename}`,
voiceUrl: `/api/upload/audio/${audioFilename}`,
voiceDuration: 3,
});
return { owner, workspace, project, video, version, imageFilename, audioFilename, videoFilename };
}
function shareCookie(videoId: string, token: string): Record<string, string> {
return { [getShareSessionCookieName(videoId)]: createShareSessionValue(token, videoId, false) };
}
// A one-pixel PNG header is enough: the upload route checks magic bytes, not
// that the file decodes.
function pngFile(): File {
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]);
return new File([bytes], 'annotation.png', { type: 'image/png' });
}
// EBML header, which is what hasValidAudioMagicBytes() looks for on audio/webm.
function webmFile(): File {
const bytes = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
return new File([bytes], 'note.webm', { type: 'audio/webm' });
}
function uploadForm(field: 'image' | 'audio', videoId: string): FormData {
const form = new FormData();
form.append(field, field === 'image' ? pngFile() : webmFile());
form.append('videoId', videoId);
return form;
}
// ---------------------------------------------------------------------------
// POST /api/upload/image
// ---------------------------------------------------------------------------
// The write half of the image path. Its guard is stricter than the read half's:
// membership alone is not enough for a guest, and a share link has to carry
// COMMENT rather than VIEW. Every body below is a real multipart with a real PNG
// and a real videoId, because both upload routes reject a malformed request
// before they authorize and an empty form produces the same 400 for everybody.
describe('POST /api/upload/image', () => {
function imageRequest(videoId: string, cookies?: Record<string, string>) {
return apiRequest('/api/upload/image', {
rawBody: uploadForm('image', videoId),
headers: { 'content-length': '2048' },
cookies,
});
}
it('refuses an anonymous caller with a well-formed image and a real video id', async () => {
const fixture = await seedMedia();
signedOut();
const response = await callRoute(uploadImage, imageRequest(fixture.video.id));
expect(response.status).toBe(403);
expect(await readError(response)).toBe('Access denied');
expect(r2Send).not.toHaveBeenCalled();
});
it('refuses a signed-in stranger who owns a workspace of their own', async () => {
const fixture = await seedMedia();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(uploadImage, imageRequest(fixture.video.id));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// The positive control for both refusals above: identical request, identical
// rows, only the caller changed.
it('stores the image for the project owner and returns a proxy url', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await callRoute(uploadImage, imageRequest(fixture.video.id));
expect(response.status).toBe(201);
const { url } = await readData<{ url: string }>(response);
expect(url).toMatch(/^\/api\/upload\/image\/[0-9a-f-]{36}\.png$/);
expect(sentKey()).toMatch(/^images\/[0-9a-f-]{36}\.png$/);
});
// The upload gate is `hasAccess`, not `canEdit`: leaving an annotation is the
// whole point of a COMMENTATOR seat.
it('lets a project COMMENTATOR attach an image', async () => {
const fixture = await seedMedia();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(uploadImage, imageRequest(fixture.video.id));
expect(response.status).toBe(201);
});
// `hasAccess` is gated on the workspace owner's billing, so a lapsed trial
// closes the upload path for the owner too.
it('refuses the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedMedia({ ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await callRoute(uploadImage, imageRequest(fixture.video.id));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// A VIEW share link is not enough to write. The read routes accept VIEW; this
// one requires COMMENT, and the pair below is what pins the difference.
it('refuses a guest holding a VIEW-only share link', async () => {
const fixture = await seedMedia();
const link = await createShareLink({
projectId: fixture.project.id,
videoId: fixture.video.id,
permission: 'VIEW',
allowGuests: true,
});
signedOut();
const response = await callRoute(
uploadImage,
imageRequest(fixture.video.id, shareCookie(fixture.video.id, link.token))
);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// With a COMMENT link the guest is past the access check and stops one step
// later, on the upload token every guest write needs. 400 is a status no
// unauthorized caller in this describe block can reach, which is what proves
// the 403s above came from the guard.
it('gets a guest holding a COMMENT share link past the access check', async () => {
const fixture = await seedMedia();
const link = await createShareLink({
projectId: fixture.project.id,
videoId: fixture.video.id,
permission: 'COMMENT',
allowGuests: true,
});
signedOut();
const response = await callRoute(
uploadImage,
imageRequest(fixture.video.id, shareCookie(fixture.video.id, link.token))
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('uploadToken is required');
});
});
// ---------------------------------------------------------------------------
// POST /api/upload/audio
// ---------------------------------------------------------------------------
describe('POST /api/upload/audio', () => {
function audioRequest(videoId: string) {
return apiRequest('/api/upload/audio', { rawBody: uploadForm('audio', videoId) });
}
it('refuses an anonymous caller with a well-formed voice note and a real video id', async () => {
const fixture = await seedMedia();
signedOut();
const response = await callRoute(uploadAudio, audioRequest(fixture.video.id));
expect(response.status).toBe(403);
expect(await readError(response)).toBe('Access denied');
expect(r2Send).not.toHaveBeenCalled();
});
it('refuses a signed-in stranger who owns a workspace of their own', async () => {
const fixture = await seedMedia();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(uploadAudio, audioRequest(fixture.video.id));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('stores the voice note for the project owner and returns a proxy url', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await callRoute(uploadAudio, audioRequest(fixture.video.id));
expect(response.status).toBe(201);
const { url } = await readData<{ url: string }>(response);
expect(url).toMatch(/^\/api\/upload\/audio\/[0-9a-f-]{36}\.webm$/);
expect(sentKey()).toMatch(/^voice\/[0-9a-f-]{36}\.webm$/);
});
it('lets a workspace COMMENTATOR attach a voice note', async () => {
const fixture = await seedMedia();
const commentator = await createUser();
await addWorkspaceMember({
workspaceId: fixture.workspace.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(uploadAudio, audioRequest(fixture.video.id));
expect(response.status).toBe(201);
});
it('refuses the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedMedia({ ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await callRoute(uploadAudio, audioRequest(fixture.video.id));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// Cross-tenant identifier substitution. The caller is a perfectly legitimate
// user of their own project and swaps in a video id from another workspace.
it('refuses a video id belonging to another workspace', async () => {
const theirs = await seedMedia();
const mine = await seedMedia();
signedInAs(mine.owner);
const response = await callRoute(uploadAudio, audioRequest(theirs.video.id));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('lets the same caller upload against their own video', async () => {
await seedMedia();
const mine = await seedMedia();
signedInAs(mine.owner);
const response = await callRoute(uploadAudio, audioRequest(mine.video.id));
expect(response.status).toBe(201);
});
});
// ---------------------------------------------------------------------------
// GET /api/upload/image/[filename]
// ---------------------------------------------------------------------------
// The read half. There is no project or video id in the URL: the route works
// backwards from the filename to whichever comment, asset or thumbnail
// references it, and authorizes against that project. So the filename alone
// decides which tenant gets checked, and a caller who knows one from another
// workspace is the case that matters.
describe('GET /api/upload/image/[filename]', () => {
function serve(filename: string, init: { cookies?: Record<string, string> } = {}) {
return callRoute(serveImage, apiRequest(`/api/upload/image/${filename}`, init), { filename });
}
it('refuses an anonymous caller on a private project', async () => {
const fixture = await seedMedia();
signedOut();
const response = await serve(fixture.imageFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// The positive control for the case above: same anonymous caller, same
// filename, only the project's visibility changed. A PUBLIC project is meant
// to render for a passer-by, annotations included.
it('serves an anonymous caller the same image once the project is PUBLIC', async () => {
const fixture = await seedMedia({ visibility: 'PUBLIC' });
signedOut();
const response = await serve(fixture.imageFilename);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(STORED_BYTES);
expect(sentKey()).toBe(`images/${fixture.imageFilename}`);
});
it('refuses a signed-in stranger', async () => {
const fixture = await seedMedia();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await serve(fixture.imageFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('serves the project owner', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(fixture.imageFilename);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/png');
expect(response.headers.get('cache-control')).toBe('private, no-store');
});
// The sandboxing headers are the reason a stored .png that is really an HTML
// document cannot run in the app's origin.
it('serves the image with nosniff and a sandboxing content security policy', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(fixture.imageFilename);
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(response.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox");
});
it('serves a project COMMENTATOR', async () => {
const fixture = await seedMedia();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await serve(fixture.imageFilename);
expect(response.status).toBe(200);
});
it('serves a guest holding a VIEW share link for the video the comment hangs off', async () => {
const fixture = await seedMedia();
const link = await createShareLink({
projectId: fixture.project.id,
videoId: fixture.video.id,
permission: 'VIEW',
allowGuests: true,
});
signedOut();
const response = await serve(fixture.imageFilename, {
cookies: shareCookie(fixture.video.id, link.token),
});
expect(response.status).toBe(200);
});
// A share-link session for one video must not unlock media belonging to
// another, even when the guest holds a genuine signed cookie.
it('refuses a guest whose share link is for a different video', async () => {
const theirs = await seedMedia();
const mine = await seedMedia();
const link = await createShareLink({
projectId: mine.project.id,
videoId: mine.video.id,
permission: 'VIEW',
allowGuests: true,
});
signedOut();
const response = await serve(theirs.imageFilename, {
cookies: shareCookie(mine.video.id, link.token),
});
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// 400 rather than 403, and reached before any database work. Its value here is
// as a discriminator: it is proof that the 403s above are the authorization
// check answering and not a rejected request shape.
it('rejects a filename that is not a uuid before it looks anything up', async () => {
await seedMedia();
signedOut();
const response = await serve('..%2Fsecrets.png');
expect(response.status).toBe(400);
expect(await readError(response)).toBe('Invalid filename');
});
it('rejects a uuid filename carrying a traversal segment', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(`${fixture.imageFilename}/../../videos/secret.mp4`);
expect(response.status).toBe(400);
expect(r2Send).not.toHaveBeenCalled();
});
// An object nobody references is refused rather than served, so guessing a
// valid-looking uuid buys nothing.
it('refuses a well-formed filename that no row references', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(uniqueFilename('png'));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// When one filename is referenced from two different videos the route cannot
// tell which project should authorize it, and refuses rather than picking one.
// Pinned because "pick the first" would be the natural regression and it would
// hand a caller access through whichever project they happen to be in.
it('refuses an image referenced by two videos even for the owner of both', async () => {
const fixture = await seedMedia();
const secondVideo = await createVideo({ projectId: fixture.project.id, title: 'Second' });
await createVideoAsset({
videoId: secondVideo.id,
billedUserId: fixture.owner.id,
sourceUrl: `/api/upload/image/${fixture.imageFilename}`,
});
signedInAs(fixture.owner);
const response = await serve(fixture.imageFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('refuses the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedMedia({ ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await serve(fixture.imageFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// GET /api/upload/audio/[filename]
// ---------------------------------------------------------------------------
describe('GET /api/upload/audio/[filename]', () => {
function serve(filename: string, init: { cookies?: Record<string, string> } = {}) {
return callRoute(serveAudio, apiRequest(`/api/upload/audio/${filename}`, init), { filename });
}
it('refuses an anonymous caller on a private project', async () => {
const fixture = await seedMedia();
signedOut();
const response = await serve(fixture.audioFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('serves an anonymous caller the same voice note once the project is PUBLIC', async () => {
const fixture = await seedMedia({ visibility: 'PUBLIC' });
signedOut();
const response = await serve(fixture.audioFilename);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(STORED_BYTES);
expect(sentKey()).toBe(`voice/${fixture.audioFilename}`);
});
it('refuses a signed-in stranger', async () => {
const fixture = await seedMedia();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await serve(fixture.audioFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('serves the project owner with the content type its extension implies', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(fixture.audioFilename);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('audio/webm');
expect(response.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox");
});
it('serves a workspace COMMENTATOR', async () => {
const fixture = await seedMedia();
const commentator = await createUser();
await addWorkspaceMember({
workspaceId: fixture.workspace.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await serve(fixture.audioFilename);
expect(response.status).toBe(200);
});
it('serves a guest holding a VIEW share link', async () => {
const fixture = await seedMedia();
const link = await createShareLink({
projectId: fixture.project.id,
videoId: fixture.video.id,
permission: 'VIEW',
allowGuests: true,
});
signedOut();
const response = await serve(fixture.audioFilename, {
cookies: shareCookie(fixture.video.id, link.token),
});
expect(response.status).toBe(200);
});
it('rejects a filename that is not a uuid', async () => {
await seedMedia();
signedOut();
const response = await serve('recording.webm');
expect(response.status).toBe(400);
expect(await readError(response)).toBe('Invalid filename');
});
it('refuses a well-formed filename that no row references', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(uniqueFilename('webm'));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('refuses a voice note from another workspace to a caller with a project of their own', async () => {
const theirs = await seedMedia();
const mine = await seedMedia();
signedInAs(mine.owner);
const response = await serve(theirs.audioFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('serves the same caller their own voice note', async () => {
await seedMedia();
const mine = await seedMedia();
signedInAs(mine.owner);
const response = await serve(mine.audioFilename);
expect(response.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// GET /api/upload/video/[filename]
// ---------------------------------------------------------------------------
// The one that matters most: this streams the source master, and it is the route
// a <video> element hits for every direct upload in the product.
describe('GET /api/upload/video/[filename]', () => {
function serve(
filename: string,
init: { cookies?: Record<string, string>; headers?: Record<string, string> } = {}
) {
return callRoute(serveVideo, apiRequest(`/api/upload/video/${filename}`, init), { filename });
}
it('refuses an anonymous caller on a private project', async () => {
const fixture = await seedMedia();
signedOut();
const response = await serve(fixture.videoFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('streams the master to an anonymous caller once the project is PUBLIC', async () => {
const fixture = await seedMedia({ visibility: 'PUBLIC' });
signedOut();
const response = await serve(fixture.videoFilename);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(STORED_BYTES);
expect(sentKey()).toBe(`videos/${fixture.videoFilename}`);
});
it('refuses a signed-in stranger', async () => {
const fixture = await seedMedia();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await serve(fixture.videoFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('streams the master to the project owner', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(fixture.videoFilename);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('video/mp4');
expect(response.headers.get('cache-control')).toBe('private, max-age=3600');
});
// Seeking is the reason this route exists rather than a redirect to a signed
// URL, so the range path has to survive authorization intact.
it('answers a range request from the owner with 206 and a content range', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(fixture.videoFilename, { headers: { range: 'bytes=0-4' } });
expect(response.status).toBe(206);
expect(response.headers.get('content-range')).toBe(`bytes 0-4/${STORED_BYTES.length}`);
});
it('refuses a range request from a signed-in stranger rather than serving a slice', async () => {
const fixture = await seedMedia();
const stranger = await createUser();
signedInAs(stranger);
const response = await serve(fixture.videoFilename, { headers: { range: 'bytes=0-4' } });
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('serves a project COMMENTATOR', async () => {
const fixture = await seedMedia();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await serve(fixture.videoFilename);
expect(response.status).toBe(200);
});
it('serves a guest holding a VIEW share link', async () => {
const fixture = await seedMedia();
const link = await createShareLink({
projectId: fixture.project.id,
videoId: fixture.video.id,
permission: 'VIEW',
allowGuests: true,
});
signedOut();
const response = await serve(fixture.videoFilename, {
cookies: shareCookie(fixture.video.id, link.token),
});
expect(response.status).toBe(200);
});
it('rejects a filename that is not a uuid', async () => {
await seedMedia();
signedOut();
const response = await serve('master.mp4');
expect(response.status).toBe(400);
expect(await readError(response)).toBe('Invalid filename');
});
it('refuses a well-formed filename that no version or asset references', async () => {
const fixture = await seedMedia();
signedInAs(fixture.owner);
const response = await serve(uniqueFilename('mp4'));
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
// Straight identifier substitution against the route with the least context in
// its URL. The caller has a real project and a real session; only the filename
// is somebody else's.
it('refuses a master belonging to another workspace', async () => {
const theirs = await seedMedia();
const mine = await seedMedia();
signedInAs(mine.owner);
const response = await serve(theirs.videoFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
it('serves the same caller their own master', async () => {
await seedMedia();
const mine = await seedMedia();
signedInAs(mine.owner);
const response = await serve(mine.videoFilename);
expect(response.status).toBe(200);
});
it('refuses the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedMedia({ ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await serve(fixture.videoFilename);
expect(response.status).toBe(403);
expect(r2Send).not.toHaveBeenCalled();
});
});