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.
This commit is contained in:
yusufipk
2026-07-26 13:25:11 +07:00
parent fe42c0836f
commit 0187db5dc7
55 changed files with 17028 additions and 166 deletions
+205 -22
View File
@@ -22,10 +22,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { REPO_ROOT } from '../helpers/env';
import { apiRequest, callRoute, type RouteHandler } from '../helpers/request';
import { apiRequest, callRoute, readData, type RouteHandler } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
@@ -99,6 +99,46 @@ import * as workspaceMemberRoute from '@/app/api/workspaces/[workspaceId]/member
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
// ---------------------------------------------------------------------------
@@ -582,12 +622,10 @@ const ROUTE_CASES: readonly RouteCase[] = [
module: assetsBunnyInitRoute,
url: (f) => `/api/videos/${f.videoId}/assets/bunny-init`,
params: (f) => ({ videoId: f.videoId }),
// This entry cannot be made load-bearing here, and it was verified to hold
// with `if (!context.canUploadAssets)` replaced by `if (false)`: Bunny
// uploads are unconfigured in the test environment, so the route answers 400
// one line below the guard whether or not the guard is there. The real
// coverage for it is in tests/api/assets-authz.test.ts, which asserts the
// exact 403 for a stranger next to the exact 400 for a member.
// 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' },
},
{
@@ -602,13 +640,11 @@ const ROUTE_CASES: readonly RouteCase[] = [
module: assetsRoute,
url: (f) => `/api/videos/${f.videoId}/assets`,
params: (f) => ({ videoId: f.videoId }),
// The body carries no `provider`, so POST answers 400 "Invalid provider"
// just below the access check. Verified: with
// `if (!context.canUploadAssets)` replaced by `if (false)` this entry still
// passes. Sending a real provider would not fix it, because every branch
// that could reach 201 needs a live R2 or YouTube call. The exact-status
// coverage lives in tests/api/assets-authz.test.ts instead. The GET half of
// this module is genuinely load-bearing here: it 403s on the access check.
// 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}` },
},
{
@@ -669,6 +705,68 @@ const ROUTE_CASES: readonly RouteCase[] = [
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[] = [];
@@ -765,6 +863,39 @@ describe('auth matrix', () => {
// 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);
}
}
});
}
@@ -778,22 +909,39 @@ describe('auth matrix', () => {
});
// -------------------------------------------------------------------------
// Signed in, but not an admin
// 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 these
// tests green while handing the admin endpoints to any signed-in user. These
// two cases are what separate "no session" from "not an admin".
describe('admin routes reject a signed-in non-admin', () => {
// 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] and keeps the row', async () => {
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(
@@ -806,7 +954,20 @@ describe('auth matrix', () => {
expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(1);
});
it('refuses POST /api/admin/stats/refresh-r2', async () => {
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(
@@ -815,6 +976,28 @@ describe('auth matrix', () => {
);
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]);
});
});
});
+42 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { notifyProjectOwner } from '@/lib/notifications';
import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session';
import {
GET as listComments,
@@ -283,6 +284,46 @@ describe('POST /api/versions/[versionId]/comments', () => {
expect(await db.comment.count()).toBe(0);
});
// The "do not email somebody about their own comment" rule lives in the route
// (`const isOwnProject = session?.user?.id === project.ownerId`), not in
// lib/notifications.ts: notifyUsers() takes no actor argument and has no way
// to know. So it cannot be covered by a unit test of the notification module,
// and until these two it was covered nowhere: deleting the guard turned
// nothing red. They are written as a pair on purpose, because the negative one
// alone would also pass if notifications stopped firing altogether.
it('does not notify the project owner about the owners own comment', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
vi.mocked(notifyProjectOwner).mockClear();
const response = await callRoute(
createCommentRoute,
apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(201);
expect(notifyProjectOwner).not.toHaveBeenCalled();
});
it('notifies the project owner about a collaborators comment', async () => {
const scenario = await seedVersion();
const collaborator = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: collaborator.id });
signedInAs(collaborator);
vi.mocked(notifyProjectOwner).mockClear();
const response = await callRoute(
createCommentRoute,
apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(201);
expect(notifyProjectOwner).toHaveBeenCalledTimes(1);
expect(vi.mocked(notifyProjectOwner).mock.calls[0][0]).toBe(scenario.owner.id);
});
it('accepts a timestamp exactly equal to the duration', async () => {
const scenario = await seedVersion({ duration: 120 });
signedInAs(scenario.owner);
+392
View File
@@ -0,0 +1,392 @@
// lib/email-verification.ts and the two routes that drive it.
//
// The property the whole module rests on is that the database never holds a
// usable verification link: it stores a SHA-256 digest, and the raw token
// exists only in the mail. Everything below is written so that storing the raw
// token, or dropping the expiry check, or letting a spent token be replayed,
// fails a test rather than a security review.
import { createHash } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import nodemailer from 'nodemailer';
import { db } from '@/lib/db';
import {
consumeVerificationToken,
createVerificationToken,
isEmailVerificationEnabled,
sendVerificationEmail,
} from '@/lib/email-verification';
import { GET as verifyEmail } from '@/app/api/auth/verify-email/route';
import { POST as resendVerification } from '@/app/api/auth/verify-email/resend/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { mailTo, sentMail } from '../helpers/mail';
import { createUser } from '../factories';
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
const RESEND_MESSAGE =
'If that email has an unverified account, a new verification link has been sent.';
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
/** Backdates the stored token so the expiry branch is reachable without waiting. */
async function expireToken(tokenHash: string): Promise<void> {
await db.verificationToken.update({
where: { token: tokenHash },
data: { expires: new Date(Date.now() - MINUTE_MS) },
});
}
describe('createVerificationToken', () => {
it('hands back a raw token and stores only its digest', async () => {
const token = await createVerificationToken('[email protected]');
const record = await db.verificationToken.findFirstOrThrow();
expect(token).toMatch(/^[0-9a-f]{64}$/);
expect(record.identifier).toBe('[email protected]');
// The load-bearing assertion: a dump of verification_tokens must not be a
// list of working verification links.
expect(record.token).not.toBe(token);
expect(record.token).toBe(sha256(token));
});
it('expires the token two hours out', async () => {
await createVerificationToken('[email protected]');
const record = await db.verificationToken.findFirstOrThrow();
const ttl = record.expires.getTime() - Date.now();
expect(ttl).toBeGreaterThan(TWO_HOURS_MS - MINUTE_MS);
expect(ttl).toBeLessThanOrEqual(TWO_HOURS_MS);
});
it('replaces the previous token for the address, so the older link stops working', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const first = await createVerificationToken('[email protected]');
const second = await createVerificationToken('[email protected]');
expect(second).not.toBe(first);
expect(await db.verificationToken.count()).toBe(1);
expect(await consumeVerificationToken(first)).toBeNull();
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull();
expect(await consumeVerificationToken(second)).toBe('[email protected]');
});
it('leaves tokens for other addresses alone', async () => {
const ada = await createVerificationToken('[email protected]');
await createVerificationToken('[email protected]');
expect(await db.verificationToken.count()).toBe(2);
expect(await db.verificationToken.findUnique({ where: { token: sha256(ada) } })).not.toBeNull();
});
});
describe('consumeVerificationToken', () => {
it('verifies the account and clears the token', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
expect(await consumeVerificationToken(token)).toBe('[email protected]');
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified
).toBeInstanceOf(Date);
expect(await db.verificationToken.count()).toBe(0);
});
it('refuses a replayed token and keeps the original verification timestamp', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verifiedAt = (await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified;
expect(await consumeVerificationToken(token)).toBeNull();
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toEqual(
verifiedAt
);
});
it('refuses an expired token, verifies nobody, and deletes the row', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
await expireToken(sha256(token));
expect(await consumeVerificationToken(token)).toBeNull();
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull();
expect(await db.verificationToken.count()).toBe(0);
});
// Whoever reads the database sees the digest. Presenting it back must not
// verify anything, and must not burn the live token either.
it('refuses the stored digest offered as if it were the token', async () => {
await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
const stored = (await db.verificationToken.findFirstOrThrow()).token;
expect(await consumeVerificationToken(stored)).toBeNull();
expect(await consumeVerificationToken(token)).toBe('[email protected]');
});
it('refuses a token nobody was ever issued', async () => {
expect(await consumeVerificationToken('f'.repeat(64))).toBeNull();
});
it('refuses a token for an account that is already verified, and clears it', async () => {
const verifiedAt = new Date(Date.now() - 60 * MINUTE_MS);
const user = await createUser({ email: '[email protected]', emailVerified: verifiedAt });
const token = await createVerificationToken('[email protected]');
expect(await consumeVerificationToken(token)).toBeNull();
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toEqual(
verifiedAt
);
expect(await db.verificationToken.count()).toBe(0);
});
it('refuses a token whose account no longer exists', async () => {
const token = await createVerificationToken('[email protected]');
expect(await consumeVerificationToken(token)).toBeNull();
});
});
describe('isEmailVerificationEnabled', () => {
it('is on with the SMTP trio configured, as .env.test has it', () => {
expect(isEmailVerificationEnabled()).toBe(true);
});
// A self-hosted deployment without a mail server has to keep working, so any
// one of the three going missing turns verification off entirely.
it.each(['SMTP_HOST', 'SMTP_USER', 'SMTP_PASSWORD'])('is off without %s', (variable) => {
vi.stubEnv(variable, '');
expect(isEmailVerificationEnabled()).toBe(false);
});
});
describe('sendVerificationEmail', () => {
it('mails a link carrying the raw token', async () => {
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
const token = 'a'.repeat(64);
await sendVerificationEmail('[email protected]', token);
const mails = mailTo('[email protected]');
expect(mails).toHaveLength(1);
expect(mails[0].subject).toBe('Verify your OpenFrame email address');
expect(mails[0].html).toContain(
`https://app.example.test/api/auth/verify-email?token=${token}`
);
});
it('escapes a token that carries query syntax', async () => {
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
await sendVerificationEmail('[email protected]', 'a b&c');
expect(mailTo('[email protected]')[0].html).toContain(
'https://app.example.test/api/auth/verify-email?token=a%20b%26c'
);
});
// Without an origin the link would be relative and the account unreachable.
// Sending a broken link is worse than sending nothing.
it('sends nothing when NEXTAUTH_URL is missing', async () => {
vi.stubEnv('NEXTAUTH_URL', '');
await sendVerificationEmail('[email protected]', 'a'.repeat(64));
expect(sentMail()).toEqual([]);
});
it('sends nothing when SMTP is not configured', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
await sendVerificationEmail('[email protected]', 'a'.repeat(64));
expect(sentMail()).toEqual([]);
});
// A mail server that is refusing connections must not turn a successful
// registration into a 500, so the rejection is swallowed here.
it('swallows a rejecting transport', async () => {
vi.mocked(nodemailer.createTransport).mockReturnValueOnce({
sendMail: vi.fn(async () => {
throw new Error('smtp is down');
}),
} as unknown as ReturnType<typeof nodemailer.createTransport>);
await expect(sendVerificationEmail('[email protected]', 'a'.repeat(64))).resolves.toBeUndefined();
expect(sentMail()).toEqual([]);
});
});
// The route is anonymous by design: the token in the query string is the only
// credential, so there is no forbidden case to test, only good and bad tokens.
describe('GET /api/auth/verify-email', () => {
function verifyRequest(token: string) {
return apiRequest('/api/auth/verify-email', { searchParams: { token } });
}
it('verifies the account and sends the visitor to the login page', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
const response = await callRoute(verifyEmail, verifyRequest(token));
expect(response.headers.get('location')).toBe('http://localhost:3000/login?verified=true');
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified
).toBeInstanceOf(Date);
expect(await db.verificationToken.count()).toBe(0);
});
// A raw token is 64 hex characters. Anything else is rejected before it can
// reach the database, which is what keeps enumeration cheap for us and not
// for the attacker.
it.each([['short'], ['g'.repeat(64)], ['A'.repeat(64)], ['']])(
'rejects the malformed token %j without touching the stored one',
async (token) => {
await createUser({ email: '[email protected]', emailVerified: null });
await createVerificationToken('[email protected]');
const response = await callRoute(verifyEmail, verifyRequest(token));
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=InvalidVerificationToken'
);
expect(await db.verificationToken.count()).toBe(1);
}
);
it('rejects an expired token and leaves the account unverified', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
await expireToken(sha256(token));
const response = await callRoute(verifyEmail, verifyRequest(token));
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=InvalidVerificationToken'
);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull();
});
it('rejects a replayed token', async () => {
await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
await callRoute(verifyEmail, verifyRequest(token));
const response = await callRoute(verifyEmail, verifyRequest(token));
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=InvalidVerificationToken'
);
});
});
describe('POST /api/auth/verify-email/resend', () => {
function resendRequest(body: unknown) {
return apiRequest('/api/auth/verify-email/resend', { body });
}
it('issues a fresh token to an unverified account and kills the previous link', async () => {
await createUser({ email: '[email protected]', emailVerified: null });
const firstToken = await createVerificationToken('[email protected]');
const response = await callRoute(
resendVerification,
resendRequest({ email: '[email protected]' })
);
expect(response.status).toBe(200);
expect(await db.verificationToken.count()).toBe(1);
const mails = mailTo('[email protected]');
expect(mails).toHaveLength(1);
const mailedToken = mails[0].html?.match(/token=([0-9a-f]{64})/)?.[1];
expect(mailedToken).toBeTruthy();
expect(mailedToken).not.toBe(firstToken);
// The mailed token is the raw one and the row still holds only a digest.
expect((await db.verificationToken.findFirstOrThrow()).token).toBe(sha256(mailedToken!));
expect(await consumeVerificationToken(firstToken)).toBeNull();
});
it('normalizes the address before looking the account up', async () => {
await createUser({ email: '[email protected]', emailVerified: null });
const response = await callRoute(
resendVerification,
resendRequest({ email: ' [email protected] ' })
);
expect(response.status).toBe(200);
expect(mailTo('[email protected]')).toHaveLength(1);
});
// The endpoint is unauthenticated, so a different answer for a known address
// would turn it into an account-existence oracle.
it('answers an unknown address exactly as it answers a real one, and mails nothing', async () => {
await createUser({ email: '[email protected]', emailVerified: null });
const known = await callRoute(resendVerification, resendRequest({ email: '[email protected]' }));
const unknown = await callRoute(
resendVerification,
resendRequest({ email: '[email protected]' })
);
const knownBody = await readData<{ message: string }>(known);
const unknownBody = await readData<{ message: string }>(unknown);
expect(unknown.status).toBe(known.status);
expect(unknownBody).toEqual(knownBody);
expect(unknownBody.message).toBe(RESEND_MESSAGE);
expect(mailTo('[email protected]')).toEqual([]);
});
it('mails nothing to an account that is already verified', async () => {
await createUser({ email: '[email protected]', emailVerified: new Date() });
const response = await callRoute(
resendVerification,
resendRequest({ email: '[email protected]' })
);
expect(response.status).toBe(200);
expect(sentMail()).toEqual([]);
expect(await db.verificationToken.count()).toBe(0);
});
it.each([[{}], [{ email: 42 }], [{ email: 'no-at-sign' }], [{ email: 'sp [email protected]' }]])(
'rejects %j with 400',
async (body) => {
const response = await callRoute(resendVerification, resendRequest(body));
expect(response.status).toBe(400);
expect(await db.verificationToken.count()).toBe(0);
expect(sentMail()).toEqual([]);
}
);
it('refuses to run at all when SMTP is not configured', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
await createUser({ email: '[email protected]', emailVerified: null });
const response = await callRoute(
resendVerification,
resendRequest({ email: '[email protected]' })
);
expect(response.status).toBe(400);
expect(await readError(response)).toBe('Email verification is not enabled');
expect(await db.verificationToken.count()).toBe(0);
});
});
+11 -2
View File
@@ -18,12 +18,21 @@ import {
} from '../factories';
describe('api test infrastructure', () => {
it('points at the test database and not at the dev one', async () => {
it('points at a test database and not at the dev one', async () => {
const [{ current_database: name }] = await db.$queryRaw<
Array<{ current_database: string }>
>`SELECT current_database()`;
expect(name).toBe('openframe_test');
// `openframe_test` is what everything uses by default. The optional suffix
// exists because this suite empties every table after every test, so two
// runs against one database destroy each other: writing several suites in
// parallel means giving each run its own database, created by hand in the
// same container and named `openframe_test_<something>`.
//
// The guard that matters is the one this leaves intact: the dev database is
// called `openframe`, which does not match, so a stray DATABASE_URL still
// cannot get this suite to truncate real data.
expect(name).toMatch(/^openframe_test(_[a-z0-9]+)?$/);
});
it('discovers every table from information_schema, so resetDb cannot drift', async () => {
+730
View File
@@ -0,0 +1,730 @@
// lib/invitations.ts, exercised directly rather than through the two member
// routes that call it. The routes decide who may invite; this module decides
// what an invitation is worth once it is accepted, which is the part that hands
// out standing access to a workspace or a project.
import { describe, expect, it, vi } from 'vitest';
import nodemailer from 'nodemailer';
import { db } from '@/lib/db';
import {
acceptInvitationTokenForUser,
acceptPendingInvitationsForUser,
buildInvitationUrl,
createOrRefreshInvitation,
getValidInvitationByToken,
sendInvitationEmail,
} from '@/lib/invitations';
import { mailTo, sentMail } from '../helpers/mail';
import {
addProjectMember,
addWorkspaceMember,
createInvitation,
createUser,
seedProject,
} from '../factories';
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
describe('createOrRefreshInvitation', () => {
it('creates a pending invitation with a normalized address and a 32-byte token', async () => {
const scenario = await seedProject();
const invitation = await createOrRefreshInvitation({
email: ' [email protected] ',
scope: 'WORKSPACE',
role: 'COMMENTATOR',
invitedById: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
expect(invitation.email).toBe('[email protected]');
expect(invitation.status).toBe('PENDING');
expect(invitation.role).toBe('COMMENTATOR');
expect(invitation.invitedById).toBe(scenario.owner.id);
expect(invitation.workspaceId).toBe(scenario.workspace.id);
expect(invitation.projectId).toBeNull();
expect(invitation.acceptedAt).toBeNull();
// randomBytes(32).toString('hex'). A short or non-random token here is the
// whole security of the accept link.
expect(invitation.token).toMatch(/^[0-9a-f]{64}$/);
// The TTL is 7 days; allow a minute for the round trip.
const ttl = invitation.expiresAt.getTime() - Date.now();
expect(ttl).toBeGreaterThan(SEVEN_DAYS_MS - MINUTE_MS);
expect(ttl).toBeLessThanOrEqual(SEVEN_DAYS_MS);
});
it('refreshes the live invitation in place and rotates its token', async () => {
const scenario = await seedProject();
const args = {
email: '[email protected]',
scope: 'PROJECT' as const,
invitedById: scenario.owner.id,
projectId: scenario.project.id,
};
const first = await createOrRefreshInvitation({ ...args, role: 'COMMENTATOR' });
const second = await createOrRefreshInvitation({ ...args, role: 'ADMIN' });
expect(second.id).toBe(first.id);
expect(await db.invitation.count()).toBe(1);
// Re-inviting has to invalidate the link already in someone's inbox.
expect(second.token).not.toBe(first.token);
expect(second.role).toBe('ADMIN');
expect(second.expiresAt.getTime()).toBeGreaterThanOrEqual(first.expiresAt.getTime());
});
it('expires a stale pending invitation rather than reviving it', async () => {
const scenario = await seedProject();
const stale = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
expiresAt: new Date(Date.now() - MINUTE_MS),
});
const fresh = await createOrRefreshInvitation({
email: '[email protected]',
scope: 'WORKSPACE',
role: 'COMMENTATOR',
invitedById: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
expect(fresh.id).not.toBe(stale.id);
expect((await db.invitation.findUniqueOrThrow({ where: { id: stale.id } })).status).toBe(
'EXPIRED'
);
expect(fresh.status).toBe('PENDING');
expect(await db.invitation.count()).toBe(2);
});
// Two concurrent invites can leave two live rows for one address. The next
// call has to collapse them, or a cancelled invitation still has a working
// twin in the database.
it('leaves exactly one live invitation when duplicates already exist', async () => {
const scenario = await seedProject();
for (let i = 0; i < 2; i++) {
await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
});
}
const refreshed = await createOrRefreshInvitation({
email: '[email protected]',
scope: 'WORKSPACE',
role: 'ADMIN',
invitedById: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
const pending = await db.invitation.findMany({ where: { status: 'PENDING' } });
expect(pending.map((row) => row.id)).toEqual([refreshed.id]);
expect(await db.invitation.count({ where: { status: 'CANCELED' } })).toBe(1);
expect(await db.invitation.count()).toBe(2);
});
it('keeps a workspace invitation and a project invitation for one address apart', async () => {
const scenario = await seedProject();
const workspaceInvitation = await createOrRefreshInvitation({
email: '[email protected]',
scope: 'WORKSPACE',
role: 'ADMIN',
invitedById: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
const projectInvitation = await createOrRefreshInvitation({
email: '[email protected]',
scope: 'PROJECT',
role: 'COMMENTATOR',
invitedById: scenario.owner.id,
projectId: scenario.project.id,
});
expect(projectInvitation.id).not.toBe(workspaceInvitation.id);
expect(await db.invitation.count({ where: { status: 'PENDING' } })).toBe(2);
});
});
describe('getValidInvitationByToken', () => {
it('returns the pending invitation behind a live token', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
token: 'live-token',
});
expect((await getValidInvitationByToken('live-token'))?.id).toBe(invitation.id);
});
it('returns null for an expired token', async () => {
const scenario = await seedProject();
await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
token: 'stale-token',
expiresAt: new Date(Date.now() - MINUTE_MS),
});
expect(await getValidInvitationByToken('stale-token')).toBeNull();
});
it.each(['ACCEPTED', 'CANCELED', 'EXPIRED'] as const)(
'returns null for a %s invitation',
async (status) => {
const scenario = await seedProject();
await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
token: 'consumed-token',
status,
});
expect(await getValidInvitationByToken('consumed-token')).toBeNull();
}
);
it('returns null for a token nobody was ever given', async () => {
expect(await getValidInvitationByToken('not-a-real-token')).toBeNull();
});
});
describe('acceptInvitationTokenForUser', () => {
// The role carried by the invitation is the only thing that decides the
// membership role. A COMMENTATOR invite that lands as an ADMIN membership is
// a silent privilege escalation, so both scopes are pinned in both roles.
it.each([
['COMMENTATOR', 'COMMENTATOR'],
['ADMIN', 'ADMIN'],
] as const)('a %s workspace invitation grants exactly %s', async (invitedRole, memberRole) => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
role: invitedRole,
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
expect(result).toBe('accepted');
const membership = await db.workspaceMember.findUniqueOrThrow({
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: invitee.id } },
});
expect(membership.role).toBe(memberRole);
expect(await db.projectMember.count()).toBe(0);
});
it.each([
['COMMENTATOR', 'COMMENTATOR'],
['ADMIN', 'ADMIN'],
] as const)('a %s project invitation grants exactly %s', async (invitedRole, memberRole) => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
role: invitedRole,
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
expect(result).toBe('accepted');
const membership = await db.projectMember.findUniqueOrThrow({
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
});
expect(membership.role).toBe(memberRole);
expect(await db.workspaceMember.count()).toBe(0);
});
it('consumes the invitation and stamps acceptedAt', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
});
await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
const stored = await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } });
expect(stored.status).toBe('ACCEPTED');
expect(stored.acceptedAt).toBeInstanceOf(Date);
});
it('refuses the same token a second time and leaves the membership as it was', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
role: 'COMMENTATOR',
});
const accept = () =>
acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
expect(await accept()).toBe('accepted');
// Someone with the link should not be able to undo a later demotion by
// replaying it, so the second accept must not reapply the invited role.
await db.projectMember.update({
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
data: { role: 'ADMIN' },
});
expect(await accept()).toBe('not_found');
expect(await db.projectMember.count()).toBe(1);
const membership = await db.projectMember.findUniqueOrThrow({
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
});
expect(membership.role).toBe('ADMIN');
});
// An invitation is bound to an address. A forwarded link must not let the
// recipient walk into the project on their own account.
it('refuses a token issued to a different address', async () => {
const scenario = await seedProject();
const bystander = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: bystander.id,
email: bystander.email!,
});
expect(result).toBe('forbidden');
expect(await db.projectMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
it('matches the invited address case-insensitively and ignores stray whitespace', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: ' [email protected] ',
});
expect(result).toBe('accepted');
expect(await db.workspaceMember.count()).toBe(1);
});
it('reports an expired invitation as expired, flips the row, and grants nothing', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
expiresAt: new Date(Date.now() - MINUTE_MS),
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
expect(result).toBe('expired');
expect(await db.projectMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'EXPIRED'
);
});
it.each(['ACCEPTED', 'CANCELED', 'EXPIRED'] as const)(
'refuses a %s invitation without granting a membership',
async (status) => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
status,
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
expect(result).toBe('not_found');
expect(await db.projectMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
status
);
}
);
it('reports an unknown token as not_found', async () => {
const user = await createUser();
const result = await acceptInvitationTokenForUser({
token: 'not-a-real-token',
userId: user.id,
email: user.email!,
});
expect(result).toBe('not_found');
});
it('promotes an existing COMMENTATOR to ADMIN without duplicating the membership', async () => {
const scenario = await seedProject();
const member = await createUser({ email: '[email protected]' });
await addProjectMember({
projectId: scenario.project.id,
userId: member.id,
role: 'COMMENTATOR',
});
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
role: 'ADMIN',
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: member.id,
email: member.email!,
});
expect(result).toBe('accepted');
expect(await db.projectMember.count()).toBe(1);
const membership = await db.projectMember.findUniqueOrThrow({
where: { projectId_userId: { projectId: scenario.project.id, userId: member.id } },
});
expect(membership.role).toBe('ADMIN');
});
// The invited role wins over the role the member already holds, so accepting
// a COMMENTATOR invitation demotes a sitting workspace ADMIN. Pinned because
// it is a privilege change, and a surprising one: the accept link looks like
// it can only ever add access.
it('applies a COMMENTATOR invitation over an existing ADMIN membership', async () => {
const scenario = await seedProject();
const member = await createUser({ email: '[email protected]' });
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: member.id,
role: 'ADMIN',
});
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
role: 'COMMENTATOR',
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: member.id,
email: member.email!,
});
expect(result).toBe('accepted');
const membership = await db.workspaceMember.findUniqueOrThrow({
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: member.id } },
});
expect(membership.role).toBe('COMMENTATOR');
});
// The owner already outranks any membership row. Writing one would put them
// in their own member list and, for a COMMENTATOR invitation, next to a role
// that reads as a demotion.
it('gives the workspace owner no member row yet still consumes the invitation', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: scenario.owner.email!,
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
role: 'COMMENTATOR',
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: scenario.owner.id,
email: scenario.owner.email!,
});
expect(result).toBe('accepted');
expect(await db.workspaceMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'ACCEPTED'
);
});
it('gives the project owner no member row yet still consumes the invitation', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: scenario.owner.email!,
scope: 'PROJECT',
projectId: scenario.project.id,
role: 'ADMIN',
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: scenario.owner.id,
email: scenario.owner.email!,
});
expect(result).toBe('accepted');
expect(await db.projectMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'ACCEPTED'
);
});
// Pins today's behaviour for a malformed row (scope WORKSPACE with no
// workspaceId): the caller is told "accepted" while nothing is granted and
// the invitation stays PENDING, so the accept page shows a success screen.
// See the report accompanying this suite.
it('reports accepted for a scoped invitation that points at nothing', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: null,
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
expect(result).toBe('accepted');
expect(await db.workspaceMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
});
describe('acceptPendingInvitationsForUser', () => {
it('applies every live invitation for the address with the role each one carries', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const workspaceInvitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
role: 'COMMENTATOR',
});
const projectInvitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
role: 'ADMIN',
});
await acceptPendingInvitationsForUser(invitee.id, ' [email protected] ');
const workspaceMembership = await db.workspaceMember.findUniqueOrThrow({
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: invitee.id } },
});
const projectMembership = await db.projectMember.findUniqueOrThrow({
where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } },
});
expect(workspaceMembership.role).toBe('COMMENTATOR');
expect(projectMembership.role).toBe('ADMIN');
for (const id of [workspaceInvitation.id, projectInvitation.id]) {
expect((await db.invitation.findUniqueOrThrow({ where: { id } })).status).toBe('ACCEPTED');
}
});
it('expires the stale invitations instead of granting them', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const stale = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
expiresAt: new Date(Date.now() - MINUTE_MS),
});
await acceptPendingInvitationsForUser(invitee.id, invitee.email!);
expect(await db.projectMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: stale.id } })).status).toBe(
'EXPIRED'
);
});
it('ignores invitations addressed to somebody else', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const other = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
role: 'ADMIN',
});
await acceptPendingInvitationsForUser(invitee.id, invitee.email!);
expect(await db.projectMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: other.id } })).status).toBe(
'PENDING'
);
});
});
describe('buildInvitationUrl', () => {
it('points at /invitations/accept on the configured origin', () => {
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
expect(buildInvitationUrl('abc123')).toBe(
'https://app.example.test/invitations/accept?token=abc123'
);
});
it('escapes a token that carries query syntax', () => {
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
expect(buildInvitationUrl('a b&c=d')).toBe(
'https://app.example.test/invitations/accept?token=a+b%26c%3Dd'
);
});
});
describe('sendInvitationEmail', () => {
const invite = {
to: '[email protected]',
inviterName: 'Ada Lovelace',
role: 'COMMENTATOR' as const,
scope: 'WORKSPACE' as const,
targetName: 'Acme',
invitationUrl: 'https://app.example.test/invitations/accept?token=abc123',
};
it('mails the invited address a message carrying the accept link', async () => {
vi.stubEnv('SMTP_FROM', 'OpenFrame <[email protected]>');
expect(await sendInvitationEmail(invite)).toBe(true);
const mails = mailTo('[email protected]');
expect(mails).toHaveLength(1);
expect(mails[0].from).toBe('OpenFrame <[email protected]>');
expect(mails[0].subject).toBe('[OpenFrame] You were invited to a workspace: Acme');
expect(mails[0].html).toContain('https://app.example.test/invitations/accept?token=abc123');
expect(mails[0].html).toContain('Ada Lovelace');
expect(mails[0].html).toContain('Commentator');
});
it('names the project scope and the Admin role in a project admin invitation', async () => {
expect(
await sendInvitationEmail({
...invite,
scope: 'PROJECT',
role: 'ADMIN',
targetName: 'Launch Film',
})
).toBe(true);
const mails = mailTo('[email protected]');
expect(mails[0].subject).toBe('[OpenFrame] You were invited to a project: Launch Film');
expect(mails[0].html).toContain('Admin');
expect(mails[0].html).not.toContain('Commentator');
});
// The inviter's display name and the target name are user-supplied and land
// in an HTML mail body.
it('escapes markup in the inviter and target names', async () => {
await sendInvitationEmail({
...invite,
inviterName: '<script>alert(1)</script>',
targetName: '<img src=x onerror=alert(2)>',
});
const html = mailTo('[email protected]')[0].html!;
expect(html).not.toContain('<script>');
expect(html).not.toContain('<img src=x');
expect(html).toContain('&lt;script&gt;');
});
// The member routes call this with `void`, so a rejection here would surface
// as an unhandled rejection rather than as a failed invite.
it('reports failure instead of throwing when the transport rejects', async () => {
vi.mocked(nodemailer.createTransport).mockReturnValueOnce({
sendMail: vi.fn(async () => {
throw new Error('smtp is down');
}),
} as unknown as ReturnType<typeof nodemailer.createTransport>);
expect(await sendInvitationEmail(invite)).toBe(false);
expect(sentMail()).toEqual([]);
});
it('sends nothing and reports failure when SMTP is not configured', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
expect(await sendInvitationEmail(invite)).toBe(false);
expect(sentMail()).toEqual([]);
});
});
+982
View File
@@ -0,0 +1,982 @@
// Exercises lib/admin-stats.ts, the aggregation behind the two admin
// dashboards, against real Postgres.
//
// Two things make the module worth a suite of its own rather than coverage
// through the pages that render it. First, `getCachedUserBunnyStorage` is not
// only a dashboard number: lib/storage-quota.ts adds its answer to a user's
// used bytes before granting an upload, so a grouping mistake here refuses or
// grants real uploads. Second, every figure is a byte count, and the
// download-egress path carries a BigInt column into a JavaScript number, which
// is exactly the seam this repo has already been bitten on.
//
// Only the boundaries are faked: `r2Client` (ListObjectsV2), global `fetch`
// (the Bunny API) and `getStripe()`. Every row the module aggregates over is a
// real row in the test database, because the aggregation is the thing under
// test.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// tests/setup/api.ts replaces `getCachedUserBunnyStorage` with a vi.fn() that
// answers `{}`, because it sits in the middle of reserveStorageQuota() and
// would otherwise reach the Bunny API from every upload test. This file is the
// one place that has to run the real implementation, so it drops that
// registration entirely. `vi.unmock` is hoisted like `vi.mock`, so it lands
// after the setup file's registration and wins. `mockRestore()` would not do
// the job: the stub is a bare vi.fn(), not a spy over the real export, so
// restoring it yields a function that returns undefined.
vi.unmock('@/lib/admin-stats');
import { DownloadEgressSource, VideoAssetKind, VideoAssetProvider } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import {
getCachedBunnyStorageStats,
getCachedStripeStats,
getCachedTotalStorage,
getCachedUserBunnyStorage,
getCachedUserDownloadEgress,
getCachedUserMediaStorage,
refreshR2StorageSnapshot,
} from '@/lib/admin-stats';
import {
createComment,
createProject,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
nextSeq,
seedProject,
seedVersion,
} from '../factories';
// ---------------------------------------------------------------------------
// R2 boundary
// ---------------------------------------------------------------------------
// vi.mock factories are hoisted above every const in the file, so the recorder
// has to be hoisted with them. The setup file stubs the named helpers in
// `@/lib/r2` but not `r2Client`, which is what listAllR2FileSizes() reaches
// for.
const r2 = vi.hoisted(() => ({
bucket: 'openframe-admin-stats-test-bucket',
/** Successive ListObjectsV2 responses, one consumed per send(). */
pages: [] as Array<{
Contents?: Array<{ Key?: string; Size?: number }>;
IsTruncated?: boolean;
NextContinuationToken?: string;
}>,
/** The continuation token on each send(), in call order. */
requestedTokens: [] as Array<string | undefined>,
/** The bucket on each send(), in call order. */
requestedBuckets: [] as string[],
/** Set to make the next send() reject, for the storage-unreachable path. */
failure: null as Error | null,
}));
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; ContinuationToken?: string } }) => {
if (r2.failure) throw r2.failure;
r2.requestedBuckets.push(command.input?.Bucket ?? '');
r2.requestedTokens.push(command.input?.ContinuationToken);
return r2.pages[r2.requestedTokens.length - 1] ?? { Contents: [], IsTruncated: false };
},
},
};
});
// The snapshot lives on globalThis so it survives a Next.js server render, which
// also means it survives from one test to the next. Clear it, or a test that
// expects "no snapshot yet" passes or fails on file order.
const adminGlobals = globalThis as unknown as {
adminR2StorageSnapshot?: unknown;
adminR2StorageSnapshotPromise?: unknown;
};
beforeEach(() => {
delete adminGlobals.adminR2StorageSnapshot;
delete adminGlobals.adminR2StorageSnapshotPromise;
r2.pages = [];
r2.requestedTokens.length = 0;
r2.requestedBuckets.length = 0;
r2.failure = null;
// Every degrade path in this module logs through logError(). Keep the suite
// output readable without hiding a genuine failure.
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/**
* Takes an R2 snapshot holding exactly these keys and sizes.
*
* The recorders are cleared first because the fake client picks its response by
* call count, so a second snapshot in the same test would otherwise run off the
* end of the script and list an empty bucket.
*/
async function snapshotBucket(fileSizes: Record<string, number>): Promise<string> {
r2.requestedTokens.length = 0;
r2.requestedBuckets.length = 0;
r2.pages = [
{
Contents: Object.entries(fileSizes).map(([Key, Size]) => ({ Key, Size })),
IsTruncated: false,
},
];
return refreshR2StorageSnapshot();
}
// ---------------------------------------------------------------------------
// Bunny boundary
// ---------------------------------------------------------------------------
interface BunnyPage {
status?: number;
body?: unknown;
}
interface BunnyCalls {
urls: string[];
accessKeys: Array<string | undefined>;
}
/** Answers the Bunny video-list endpoint with these pages, in order. */
function stubBunnyPages(pages: BunnyPage[]): BunnyCalls {
const calls: BunnyCalls = { urls: [], accessKeys: [] };
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown, init?: { headers?: Record<string, string> }) => {
calls.urls.push(String(input));
calls.accessKeys.push(init?.headers?.AccessKey);
// Past the end of the script, answer an empty page so a runaway loop
// terminates instead of hanging the suite.
const page = pages[calls.urls.length - 1] ?? { body: { items: [] } };
const status = page.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
json: async () => page.body,
};
})
);
return calls;
}
/** Credentials plus a one-page library holding exactly these videos. */
function stubBunnyLibrary(sizes: Record<string, number>): BunnyCalls {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
const items = Object.entries(sizes).map(([guid, storageSize]) => ({ guid, storageSize }));
return stubBunnyPages([{ body: { items, totalItems: items.length } }]);
}
function bunnyCredentials(): void {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
}
// ---------------------------------------------------------------------------
// Download egress
// ---------------------------------------------------------------------------
// DownloadEgressEvent carries no foreign keys (see prisma/schema.prisma), so
// the ids other than billedUserId can be synthetic. There is no factory for it
// and tests/factories is shared, so the builder lives here.
async function recordDownload(billedUserId: string, estimatedBytes: bigint): Promise<void> {
const seq = nextSeq();
await db.downloadEgressEvent.create({
data: {
versionId: `egress-version-${seq}`,
videoId: `egress-video-${seq}`,
projectId: `egress-project-${seq}`,
workspaceId: `egress-workspace-${seq}`,
billedUserId,
source: DownloadEgressSource.ORIGINAL,
estimatedBytes,
},
});
}
// ---------------------------------------------------------------------------
// Stripe boundary
// ---------------------------------------------------------------------------
/** Installs a Stripe double whose price lookup answers this price. */
function stubStripePrice(price: { unit_amount?: number | null; currency?: string }): {
retrievedPriceIds: string[];
} {
const retrievedPriceIds: string[] = [];
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
prices: {
retrieve: vi.fn(async (priceId: string) => {
retrievedPriceIds.push(priceId);
return price;
}),
},
});
return { retrievedPriceIds };
}
describe('refreshR2StorageSnapshot', () => {
it('walks every page of the bucket listing and totals the object sizes', async () => {
r2.pages = [
{
Contents: [
{ Key: 'voice/one.webm', Size: 100 },
{ Key: 'images/two.png', Size: 250 },
],
IsTruncated: true,
NextContinuationToken: 'page-two',
},
{
Contents: [{ Key: 'videos/three.mp4', Size: 1000 }],
IsTruncated: false,
},
];
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(1350);
// The second request must carry the token the first one handed back, or the
// listing silently stops at 1000 objects and every total is wrong.
expect(r2.requestedTokens).toEqual([undefined, 'page-two']);
expect(r2.requestedBuckets).toEqual([r2.bucket, r2.bucket]);
});
it('counts an object with no reported size as zero and skips one with no key', async () => {
r2.pages = [
{
Contents: [
{ Key: 'voice/sizeless.webm' },
{ Key: 'images/known.png', Size: 40 },
{ Size: 999 },
],
IsTruncated: false,
},
];
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(40);
});
it('reports an empty bucket as zero bytes rather than as unavailable', async () => {
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(0);
});
it('answers the moment of the refresh as an ISO timestamp', async () => {
const before = Date.now();
const refreshedAt = await refreshR2StorageSnapshot();
expect(refreshedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(Date.parse(refreshedAt)).toBeGreaterThanOrEqual(before);
expect(Date.parse(refreshedAt)).toBeLessThanOrEqual(Date.now());
});
it('propagates a storage failure to the caller instead of storing a partial snapshot', async () => {
await snapshotBucket({ 'images/kept.png': 77 });
r2.failure = new Error('storage is unreachable');
await expect(refreshR2StorageSnapshot()).rejects.toThrow('storage is unreachable');
// The previous snapshot survives, so the dashboard keeps showing the last
// known figure rather than dropping to the -1 sentinel.
r2.failure = null;
expect(await getCachedTotalStorage()).toBe(77);
});
});
describe('getCachedTotalStorage', () => {
it('answers -1 when no snapshot has been taken yet', async () => {
expect(await getCachedTotalStorage()).toBe(-1);
});
it('never touches storage on its own, so the sentinel is not a listing failure', async () => {
await getCachedTotalStorage();
expect(r2.requestedTokens).toEqual([]);
});
it('answers the byte total of the most recent snapshot', async () => {
await snapshotBucket({ 'images/a.png': 5 });
expect(await getCachedTotalStorage()).toBe(5);
await snapshotBucket({ 'images/a.png': 5, 'voice/b.webm': 15 });
expect(await getCachedTotalStorage()).toBe(20);
});
});
describe('getCachedBunnyStorageStats', () => {
it('totals the library and indexes each video by its guid', async () => {
const calls = stubBunnyLibrary({ 'bunny-one': 500, 'bunny-two': 250 });
const stats = await getCachedBunnyStorageStats();
expect(stats).toEqual({ totalBytes: 750, byVideoId: { 'bunny-one': 500, 'bunny-two': 250 } });
expect(calls.urls).toHaveLength(1);
expect(calls.urls[0]).toBe(
'https://video.bunnycdn.com/library/4242/videos?page=1&itemsPerPage=100'
);
expect(calls.accessKeys).toEqual(['bunny-key-for-admin-stats']);
});
it('stops requesting pages once the reported item count is covered', async () => {
bunnyCredentials();
const calls = stubBunnyPages([
{ body: { items: [{ guid: 'bunny-one', storageSize: 10 }], totalItems: 1 } },
]);
const stats = await getCachedBunnyStorageStats();
expect(stats.totalBytes).toBe(10);
expect(calls.urls).toHaveLength(1);
});
it('keeps paging when the API reports no total and stops at the first empty page', async () => {
bunnyCredentials();
const calls = stubBunnyPages([
{ body: { items: [{ guid: 'bunny-one', storageSize: 10 }] } },
{ body: { items: [{ guid: 'bunny-two', storageSize: 32 }] } },
{ body: { items: [] } },
]);
const stats = await getCachedBunnyStorageStats();
expect(stats).toEqual({ totalBytes: 42, byVideoId: { 'bunny-one': 10, 'bunny-two': 32 } });
expect(calls.urls).toHaveLength(3);
expect(calls.urls[1]).toContain('page=2');
expect(calls.urls[2]).toContain('page=3');
});
it('reads the capitalised item list Bunny sometimes returns', async () => {
bunnyCredentials();
stubBunnyPages([{ body: { Items: [{ guid: 'bunny-one', storageSize: 64 }], TotalItems: 1 } }]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 64,
byVideoId: { 'bunny-one': 64 },
});
});
it('accepts any of the three size field names and treats an absent one as zero', async () => {
bunnyCredentials();
stubBunnyPages([
{
body: {
items: [
{ guid: 'by-storage-size', storageSize: 5 },
{ guid: 'by-storage', storage: 7 },
{ guid: 'by-size', size: 11 },
{ guid: 'no-size-at-all' },
{ guid: 'negative-size', size: -400 },
],
totalItems: 5,
},
},
]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 23,
byVideoId: {
'by-storage-size': 5,
'by-storage': 7,
'by-size': 11,
'no-size-at-all': 0,
'negative-size': 0,
},
});
});
it('skips an item with no guid rather than indexing it under an empty key', async () => {
bunnyCredentials();
stubBunnyPages([
{
body: {
items: [{ storageSize: 900 }, { guid: 'bunny-one', storageSize: 3 }],
totalItems: 2,
},
},
]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 3,
byVideoId: { 'bunny-one': 3 },
});
});
// -1 rather than 0 is the whole point: 0 would tell the dashboard that Bunny
// holds nothing, and would tell getCachedUserBunnyStorage to charge every
// user zero bytes.
it('degrades to -1 instead of throwing when the Bunny API answers an error', async () => {
bunnyCredentials();
stubBunnyPages([{ status: 500, body: {} }]);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
});
it('degrades to -1 instead of throwing when fetch itself fails', async () => {
bunnyCredentials();
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('network timeout');
})
);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
});
// The flag defaults to on, so a self-hosted deployment that never configured
// Bunny lands here: credentials missing while the feature is nominally
// enabled.
it('degrades to -1 when the Bunny credentials are not configured', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', undefined);
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', undefined);
const calls = stubBunnyPages([]);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
expect(calls.urls).toEqual([]);
});
it('reports a genuine zero without calling Bunny when the feature flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', 'false');
const calls = stubBunnyLibrary({ 'bunny-one': 500 });
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: 0, byVideoId: {} });
expect(calls.urls).toEqual([]);
});
it('falls back to the public library id when the server-side one is unset', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', '7171');
const calls = stubBunnyPages([{ body: { items: [], totalItems: 0 } }]);
await getCachedBunnyStorageStats();
expect(calls.urls[0]).toContain('/library/7171/videos');
});
});
// This block is the reason the file unmocks `@/lib/admin-stats`: everywhere
// else in the api project these calls answer `{}`.
describe('getCachedUserBunnyStorage', () => {
it('attributes each Bunny video to the owner of the project it lives in', async () => {
const first = await seedProject();
const second = await seedProject();
const firstVideo = await createVideo({ projectId: first.project.id });
const secondVideo = await createVideo({ projectId: second.project.id });
await createVersion({
videoParentId: firstVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
await createVersion({
videoParentId: secondVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-second',
});
stubBunnyLibrary({ 'bunny-first': 500, 'bunny-second': 900 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [first.owner.id]: 500, [second.owner.id]: 900 });
});
it('adds up several Bunny videos owned by the same user', async () => {
const scenario = await seedProject();
const firstVideo = await createVideo({ projectId: scenario.project.id });
const secondVideo = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: firstVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
await createVersion({
videoParentId: secondVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-second',
});
stubBunnyLibrary({ 'bunny-first': 500, 'bunny-second': 900 });
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 1400 });
});
it('bills a Bunny asset to the user it records as the billed user', async () => {
const scenario = await seedProject();
const uploader = await createUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-asset',
});
stubBunnyLibrary({ 'bunny-asset': 1234 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [uploader.id]: 1234 });
expect(perUser[scenario.owner.id]).toBeUndefined();
});
// Two versions of the same Bunny video (a relabelled upload, say) are one
// object in the library, so charging the owner twice would inflate the number
// that lib/storage-quota.ts checks an upload against.
it('counts a Bunny video once per user however many rows point at it', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
versionNumber: 1,
providerId: 'bunny',
providerVideoId: 'bunny-shared',
});
await createVersion({
videoParentId: video.id,
versionNumber: 2,
providerId: 'bunny',
providerVideoId: 'bunny-shared',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-shared',
});
stubBunnyLibrary({ 'bunny-shared': 700 });
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 700 });
});
it('ignores versions and assets that are not on Bunny', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'r2',
providerVideoId: 'bunny-first',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: VideoAssetProvider.R2_IMAGE,
});
stubBunnyLibrary({ 'bunny-first': 500 });
expect(await getCachedUserBunnyStorage()).toEqual({});
});
// An orphan: the row still names a Bunny video that the library no longer
// holds. It has to resolve to zero bytes rather than to undefined, or the
// arithmetic downstream turns into NaN.
it('charges zero for a Bunny video the library no longer knows about', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-deleted-upstream',
});
stubBunnyLibrary({ 'bunny-still-there': 4096 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [scenario.owner.id]: 0 });
expect(Number.isNaN(perUser[scenario.owner.id])).toBe(false);
});
// The -1 sentinel means "Bunny did not answer", and the module must not turn
// that into "this user stores nothing", because lib/storage-quota.ts would
// then hand out headroom the user does not have. An empty map at least leaves
// the R2 figures intact.
it('answers an empty map when the Bunny library could not be read', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
bunnyCredentials();
stubBunnyPages([{ status: 503, body: {} }]);
expect(await getCachedUserBunnyStorage()).toEqual({});
});
it('is empty on a database with no videos at all', async () => {
stubBunnyLibrary({ 'bunny-orphan': 999 });
expect(await getCachedUserBunnyStorage()).toEqual({});
});
});
describe('getCachedUserMediaStorage', () => {
it('splits a user comment media into voice and image and totals both', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
voiceUrl: '/api/upload/audio/note.webm',
imageUrl: '/api/upload/image/shot.png',
});
await snapshotBucket({ 'voice/note.webm': 300, 'images/shot.png': 700 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 1000, voice: 300, image: 700 },
});
});
// Comment media is billed through the workspace owner, matching the join in
// lib/storage-quota.ts, not through the owner of the project the comment
// happens to sit in.
it('bills comment media to the workspace owner rather than the project owner', async () => {
const workspaceOwner = await createUser();
const projectOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
const video = await createVideo({ projectId: project.id });
const version = await createVersion({ videoParentId: video.id });
await createComment({ versionId: version.id, voiceUrl: '/api/upload/audio/note.webm' });
await snapshotBucket({ 'voice/note.webm': 120 });
const perUser = await getCachedUserMediaStorage();
expect(perUser).toEqual({ [workspaceOwner.id]: { total: 120, voice: 120, image: 0 } });
expect(perUser[projectOwner.id]).toBeUndefined();
});
it('adds R2 image and audio assets to the user they are billed to', async () => {
const scenario = await seedProject();
const uploader = await createUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
provider: VideoAssetProvider.R2_IMAGE,
sourceUrl: '/api/upload/image/asset.png',
});
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
kind: VideoAssetKind.AUDIO,
provider: VideoAssetProvider.R2_AUDIO,
sourceUrl: '/api/upload/audio/asset.webm',
});
await snapshotBucket({ 'images/asset.png': 11, 'voice/asset.webm': 22 });
expect(await getCachedUserMediaStorage()).toEqual({
[uploader.id]: { total: 33, voice: 22, image: 11 },
});
});
it('leaves video and Bunny assets out, since they are not comment media', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.R2_VIDEO,
sourceUrl: '/api/upload/video/movie.mp4',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-one',
sourceUrl: '/api/upload/video/other.mp4',
});
await snapshotBucket({ 'images/movie.mp4': 5000, 'voice/other.mp4': 5000 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
// The same object reached through both a comment row and an asset row is one
// object in the bucket, so it must be charged once.
it('counts an object once when both a comment and an asset point at it', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
imageUrl: '/api/upload/image/same.png',
});
await createVideoAsset({
videoId: scenario.video.id,
billedUserId: scenario.owner.id,
provider: VideoAssetProvider.R2_IMAGE,
sourceUrl: '/api/upload/image/same.png',
});
await snapshotBucket({ 'images/same.png': 640 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 640, voice: 0, image: 640 },
});
});
it('keeps one user media out of another user total', async () => {
const first = await seedVersion();
const second = await seedVersion();
await createComment({
versionId: first.version.id,
imageUrl: '/api/upload/image/first.png',
});
await createComment({
versionId: second.version.id,
imageUrl: '/api/upload/image/second.png',
});
await snapshotBucket({ 'images/first.png': 100, 'images/second.png': 900 });
expect(await getCachedUserMediaStorage()).toEqual({
[first.owner.id]: { total: 100, voice: 0, image: 100 },
[second.owner.id]: { total: 900, voice: 0, image: 900 },
});
});
// An orphaned row: the comment still names a file that is no longer in the
// bucket. The user must still appear, at zero, rather than vanish or count
// NaN bytes.
it('charges zero for a comment whose file is no longer in the bucket', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
voiceUrl: '/api/upload/audio/deleted.webm',
});
await snapshotBucket({ 'images/unrelated.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 0, voice: 0, image: 0 },
});
});
it('ignores comments that carry no media at all', async () => {
const scenario = await seedVersion();
await createComment({ versionId: scenario.version.id, content: 'just text' });
await snapshotBucket({ 'images/unrelated.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
it('is empty on an empty database', async () => {
await snapshotBucket({ 'images/orphan.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
// Without a snapshot the module cannot size anything, and it answers an empty
// map rather than a map full of zeros. The difference matters: zeros would
// read on the dashboard as "this user stores nothing".
it('answers an empty map when no R2 snapshot has been taken', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
imageUrl: '/api/upload/image/shot.png',
});
expect(await getCachedUserMediaStorage()).toEqual({});
});
});
describe('getCachedUserDownloadEgress', () => {
it('sums the estimated bytes of every download billed to a user', async () => {
const first = await createUser();
const second = await createUser();
await recordDownload(first.id, BigInt(1000));
await recordDownload(first.id, BigInt(2500));
await recordDownload(second.id, BigInt(7));
expect(await getCachedUserDownloadEgress()).toEqual({
[first.id]: 3500,
[second.id]: 7,
});
});
it('is empty when nothing has been downloaded', async () => {
await createUser();
expect(await getCachedUserDownloadEgress()).toEqual({});
});
it('reports zero for a user whose downloads all measured zero bytes', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(0));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 0 });
});
// estimatedBytes is a BigInt column and the dashboard wants a number. A
// 9 PB total is not realistic, but the conversion is the same one every row
// goes through, and the answer must be bytes rather than any other unit.
it('returns the sum in bytes, not in any larger unit', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(5) * BigInt(1024) * BigInt(1024));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 5_242_880 });
});
it('carries a sum that fits in a double across exactly', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(Number.MAX_SAFE_INTEGER));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 9_007_199_254_740_991 });
});
// Past 2^53 a Number() cast silently rounds. The module clamps instead, so an
// absurd total reads as "at the ceiling" rather than as a quietly wrong
// figure.
it('clamps a sum beyond the safe integer range instead of rounding it', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(Number.MAX_SAFE_INTEGER));
await recordDownload(user.id, BigInt(1000));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: Number.MAX_SAFE_INTEGER });
});
});
describe('getCachedStripeStats', () => {
beforeEach(() => {
// getStripe() is mocked in tests/setup/api.ts and its implementation is
// module state, so it survives afterEach. Reset it so no test inherits
// another test's price.
vi.mocked(getStripe as unknown as () => unknown).mockReset();
vi.stubEnv('STRIPE_PRICE_ID', 'price_admin_stats_test');
});
it('counts users by subscription status and prices the active ones', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'TRIALING' });
await createUser({ subscriptionStatus: 'PAST_DUE' });
await createUser({ subscriptionStatus: 'CANCELED' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'eur' });
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 2,
trialingUsers: 1,
pastDueUsers: 1,
canceledUsers: 1,
freeUsers: 3,
mrrCents: 3800,
currency: 'eur',
});
expect(stripe.retrievedPriceIds).toEqual(['price_admin_stats_test']);
});
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED are real values of the enum that
// the report has no bucket for. They must not be silently folded into one of
// the five that are reported.
it('leaves statuses it does not report out of every bucket', async () => {
await createUser({ subscriptionStatus: 'UNPAID' });
await createUser({ subscriptionStatus: 'INCOMPLETE' });
await createUser({ subscriptionStatus: 'INCOMPLETE_EXPIRED' });
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
const stats = await getCachedStripeStats();
expect(stats).toEqual({
activeSubscribers: 0,
trialingUsers: 0,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
it('reports zeros and no revenue on an empty database', async () => {
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 0,
trialingUsers: 0,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
// The user counts come from the database and are still true when Stripe is
// down, so the module keeps them and only the revenue figure degrades.
it('keeps the user counts and degrades the revenue to zero usd when Stripe fails', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'TRIALING' });
vi.mocked(getStripe as unknown as () => unknown).mockImplementation(() => {
throw new Error('Stripe is unreachable');
});
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 1,
trialingUsers: 1,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
it('degrades the revenue when the price lookup itself rejects', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
prices: {
retrieve: vi.fn(async () => {
throw new Error('price not found');
}),
},
});
const stats = await getCachedStripeStats();
expect(stats?.activeSubscribers).toBe(1);
expect(stats?.mrrCents).toBe(0);
expect(stats?.currency).toBe('usd');
});
it('treats a price with no unit amount as free rather than as NaN', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
stubStripePrice({ unit_amount: null, currency: 'gbp' });
const stats = await getCachedStripeStats();
expect(stats?.mrrCents).toBe(0);
expect(stats?.currency).toBe('gbp');
});
it('answers null without querying Stripe when the billing flag is off', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'usd' });
expect(await getCachedStripeStats()).toBeNull();
expect(stripe.retrievedPriceIds).toEqual([]);
});
it('answers null when the flag is on but Stripe is not configured', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.stubEnv('STRIPE_SECRET_KEY', undefined);
expect(await getCachedStripeStats()).toBeNull();
});
});
+502
View File
@@ -0,0 +1,502 @@
// Exercises checkProjectAccess() directly, against real rows, next to
// computeProjectAccess() on the same rows.
//
// Nothing did that before. Every existing test reaches checkProjectAccess()
// through a route and only ever sees the status code it produced, and
// tests/unit/lib/route-access.test.ts mocks it out entirely. That leaves its
// input resolution, the four queries it runs to decide who the caller is,
// uncovered: deleting the `wsOwner?.ownerId === userId` branch in lib/auth.ts,
// which is the line that makes a workspace owner an owner, failed exactly one
// test out of 984.
//
// The two functions resolve the same six inputs by different routes.
// computeProjectAccess() reads them off a project fetched with
// projectAccessInclude(); checkProjectAccess() queries for each relation
// separately. Since they were refactored onto one shared formula helper
// (resolveProjectPermissions) the formulas cannot drift, which makes it easy to
// read the refactor as a guarantee that the two functions agree. It is not one.
// The formulas are shared; the inputs are not, and they already disagree in one
// place, asserted below rather than smoothed over.
//
// Expected values are written out by hand for every actor. Comparing the two
// functions to each other would be a weaker test: a wrong answer that both
// produce would pass. Comparing both to the same hand-written table proves
// agreement and correctness at once.
import { beforeEach, describe, expect, it } from 'vitest';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import {
checkProjectAccess,
computeProjectAccess,
projectAccessInclude,
type EnrichedProjectForAccess,
} from '@/lib/auth';
import { db } from '@/lib/db';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createUser,
createWorkspace,
} from '../factories';
type Intent = 'view' | 'manage' | 'delete';
/** `undefined` stands for a caller that passes no options at all. */
const INTENTS: ReadonlyArray<Intent | undefined> = [undefined, 'view', 'manage', 'delete'];
type Actor =
| 'an anonymous caller'
| 'an outsider'
| 'a project commentator'
| 'a project admin'
| 'a workspace commentator'
| 'a workspace admin'
| 'the workspace owner'
| 'the project owner'
| 'a project owner who also owns the workspace';
const ACTORS: readonly Actor[] = [
'an anonymous caller',
'an outsider',
'a project commentator',
'a project admin',
'a workspace commentator',
'a workspace admin',
'the workspace owner',
'the project owner',
'a project owner who also owns the workspace',
];
/** Exactly the shape both functions return, so `toEqual` covers every field. */
interface ExpectedAccess {
isOwner: boolean;
isProjectMember: boolean;
isProjectAdmin: boolean;
isWorkspaceMember: boolean;
isWorkspaceAdmin: boolean;
hasAccess: boolean;
canEdit: boolean;
canDelete: boolean;
ownerBillingActive: boolean;
}
// ---------------------------------------------------------------------------
// The tables
// ---------------------------------------------------------------------------
// Three scenarios, because the two functions can only disagree over inputs and
// these are the inputs they read differently: who the caller is (nine actors),
// whether the project is public (the second table), and whether the workspace
// owner still has billing access (the third). checkProjectAccess() resolves
// billing in two different queries depending on which branch it takes, so the
// expired table is the one that catches a branch that forgets to.
const PRIVATE_ACTIVE_BILLING: Record<Actor, ExpectedAccess> = {
'an anonymous caller': {
isOwner: false,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: false,
isWorkspaceAdmin: false,
hasAccess: false,
canEdit: false,
canDelete: false,
ownerBillingActive: true,
},
// Their ADMIN roles live in an unrelated workspace, so they buy nothing here.
'an outsider': {
isOwner: false,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: false,
isWorkspaceAdmin: false,
hasAccess: false,
canEdit: false,
canDelete: false,
ownerBillingActive: true,
},
'a project commentator': {
isOwner: false,
isProjectMember: true,
isProjectAdmin: false,
isWorkspaceMember: false,
isWorkspaceAdmin: false,
hasAccess: true,
canEdit: false,
canDelete: false,
ownerBillingActive: true,
},
'a project admin': {
isOwner: false,
isProjectMember: true,
isProjectAdmin: true,
isWorkspaceMember: false,
isWorkspaceAdmin: false,
hasAccess: true,
canEdit: true,
canDelete: false,
ownerBillingActive: true,
},
'a workspace commentator': {
isOwner: false,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: true,
isWorkspaceAdmin: false,
hasAccess: true,
canEdit: false,
canDelete: false,
ownerBillingActive: true,
},
'a workspace admin': {
isOwner: false,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: true,
isWorkspaceAdmin: true,
hasAccess: true,
canEdit: true,
canDelete: false,
ownerBillingActive: true,
},
// The workspace owner has no WorkspaceMember row; the OWNER role is derived
// from workspace.ownerId, and deleting the project is theirs alone.
'the workspace owner': {
isOwner: false,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: true,
isWorkspaceAdmin: true,
hasAccess: true,
canEdit: true,
canDelete: true,
ownerBillingActive: true,
},
'the project owner': {
isOwner: true,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: false,
isWorkspaceAdmin: false,
hasAccess: true,
canEdit: true,
canDelete: true,
ownerBillingActive: true,
},
'a project owner who also owns the workspace': {
isOwner: true,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: true,
isWorkspaceAdmin: true,
hasAccess: true,
canEdit: true,
canDelete: true,
ownerBillingActive: true,
},
};
// Public only moves the two actors who had no relationship to the project.
const PUBLIC_ACTIVE_BILLING: Record<Actor, ExpectedAccess> = {
...PRIVATE_ACTIVE_BILLING,
'an anonymous caller': { ...PRIVATE_ACTIVE_BILLING['an anonymous caller'], hasAccess: true },
'an outsider': { ...PRIVATE_ACTIVE_BILLING['an outsider'], hasAccess: true },
};
/**
* With the workspace owner's billing lapsed, the identity flags still resolve
* and every permission closes, including the project owner's own. The project
* owner is a separate person with a live trial of their own: it is the
* *workspace* owner's billing that pays for the workspace.
*/
const PRIVATE_EXPIRED_BILLING: Record<Actor, ExpectedAccess> = Object.fromEntries(
ACTORS.map((actor) => [
actor,
{
...PRIVATE_ACTIVE_BILLING[actor],
hasAccess: false,
canEdit: false,
canDelete: false,
ownerBillingActive: false,
},
])
) as Record<Actor, ExpectedAccess>;
interface Scenario {
name: string;
visibility: 'PRIVATE' | 'PUBLIC';
billing: 'active' | 'expired';
expected: Record<Actor, ExpectedAccess>;
}
const SCENARIOS: readonly Scenario[] = [
{
name: 'a PRIVATE project whose workspace owner has billing access',
visibility: 'PRIVATE',
billing: 'active',
expected: PRIVATE_ACTIVE_BILLING,
},
{
name: 'a PUBLIC project whose workspace owner has billing access',
visibility: 'PUBLIC',
billing: 'active',
expected: PUBLIC_ACTIVE_BILLING,
},
{
name: 'a PRIVATE project whose workspace owner has lost billing access',
visibility: 'PRIVATE',
billing: 'expired',
expected: PRIVATE_EXPIRED_BILLING,
},
];
// ---------------------------------------------------------------------------
// The known divergence
// ---------------------------------------------------------------------------
/**
* The one place the two functions disagree, spelled out rather than filtered
* out.
*
* `shouldLoadWorkspaceRole` in lib/auth.ts skips the workspace queries for a
* project owner on `intent: 'view'`, on the reasoning that an owner passes every
* check on their own. That is true of the three permission booleans, and it is
* why this is harmless today, but it is not true of the two identity flags:
* checkProjectAccess() reports `isWorkspaceMember: false, isWorkspaceAdmin:
* false` for an owner who is in fact the workspace owner, where
* computeProjectAccess() on the same rows reports true and true.
*
* Harmless today rests on one fact and not on the design: the only consumer of
* `isWorkspaceMember` from checkProjectAccess() is
* app/api/versions/[versionId]/approvals/route.ts:37, and its
* `isOwner || isProjectMember || isWorkspaceMember` is already satisfied by
* `isOwner` for exactly the actor that diverges. Nothing consumes
* `isWorkspaceAdmin` from checkProjectAccess() at all; `canEdit` folds it in,
* and `isOwner` covers that too. The next reader of either flag inherits the
* bug, which is why this is asserted instead of hidden behind a comparison of
* `hasAccess` alone.
*
* If this stops matching, the divergence was closed: delete this function and
* the test at the bottom of the file rather than widening either of them.
*/
function knownDivergence(actor: Actor, intent: Intent | undefined): Partial<ExpectedAccess> {
const resolvesWorkspaceRole = intent === 'manage' || intent === 'delete';
if (actor === 'a project owner who also owns the workspace' && !resolvesWorkspaceRole) {
return { isWorkspaceMember: false, isWorkspaceAdmin: false };
}
return {};
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
interface Seeded {
/** The project every actor except the last one is measured against. */
projectId: string;
/** A second project in the same workspace, owned by the workspace owner. */
ownedProjectId: string;
userIdFor: (actor: Actor) => string | undefined;
}
/**
* One workspace, two projects in it, and a user for every actor.
*
* The second project matters. Almost every real workspace has projects owned by
* the person who owns the workspace (that is what signing up produces), and that
* is the only actor for whom the two functions disagree. A fixture that only had
* a project owned by somebody other than the workspace owner would agree
* everywhere and prove less than it looks.
*
* The outsider is deliberately not a blank user: they are an ADMIN of another
* workspace and of a project inside it. Both of checkProjectAccess()'s
* membership lookups are keyed on a compound unique, and if either lost its
* project or workspace half the query would still find a row for this user. A
* blank outsider cannot tell the difference.
*/
async function seedScenario(scenario: Scenario): Promise<Seeded> {
const workspaceOwner =
scenario.billing === 'active' ? await createUser() : await createExpiredUser();
const projectOwner = await createUser();
const projectAdmin = await createUser();
const projectCommentator = await createUser();
const workspaceAdmin = await createUser();
const workspaceCommentator = await createUser();
const outsider = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
visibility: scenario.visibility,
});
const ownedProject = await createProject({
ownerId: workspaceOwner.id,
workspaceId: workspace.id,
visibility: scenario.visibility,
});
await addProjectMember({
projectId: project.id,
userId: projectAdmin.id,
role: ProjectMemberRole.ADMIN,
});
await addProjectMember({
projectId: project.id,
userId: projectCommentator.id,
role: ProjectMemberRole.COMMENTATOR,
});
await addWorkspaceMember({
workspaceId: workspace.id,
userId: workspaceAdmin.id,
role: WorkspaceMemberRole.ADMIN,
});
await addWorkspaceMember({
workspaceId: workspace.id,
userId: workspaceCommentator.id,
role: WorkspaceMemberRole.COMMENTATOR,
});
const elsewhere = await createWorkspace({ ownerId: outsider.id });
const elsewhereProject = await createProject({
ownerId: outsider.id,
workspaceId: elsewhere.id,
visibility: 'PRIVATE',
});
await addWorkspaceMember({
workspaceId: elsewhere.id,
userId: outsider.id,
role: WorkspaceMemberRole.ADMIN,
});
await addProjectMember({
projectId: elsewhereProject.id,
userId: outsider.id,
role: ProjectMemberRole.ADMIN,
});
const userIds: Record<Actor, string | undefined> = {
'an anonymous caller': undefined,
'an outsider': outsider.id,
'a project commentator': projectCommentator.id,
'a project admin': projectAdmin.id,
'a workspace commentator': workspaceCommentator.id,
'a workspace admin': workspaceAdmin.id,
'the workspace owner': workspaceOwner.id,
'the project owner': projectOwner.id,
'a project owner who also owns the workspace': workspaceOwner.id,
};
return {
projectId: project.id,
ownedProjectId: ownedProject.id,
userIdFor: (actor) => userIds[actor],
};
}
/** The project an actor is measured against. */
function projectIdFor(actor: Actor, seeded: Seeded): string {
return actor === 'a project owner who also owns the workspace'
? seeded.ownedProjectId
: seeded.projectId;
}
/** The project as a route fetches it, with everything computeProjectAccess reads. */
async function fetchEnriched(
projectId: string,
userId: string | undefined
): Promise<EnrichedProjectForAccess> {
return db.project.findUniqueOrThrow({
where: { id: projectId },
include: projectAccessInclude(userId),
});
}
// ---------------------------------------------------------------------------
// The matrix
// ---------------------------------------------------------------------------
// hasBillingAccess() short-circuits to true when Stripe is disabled, which would
// flatten the expired scenario into the active one. OPENFRAME_ENABLE_STRIPE is
// "true" in .env.test, so the gate is armed for every test here.
for (const scenario of SCENARIOS) {
describe(`checkProjectAccess on ${scenario.name}`, () => {
let seeded: Seeded;
beforeEach(async () => {
seeded = await seedScenario(scenario);
});
for (const actor of ACTORS) {
it(`resolves ${actor} exactly as computeProjectAccess does, at every intent`, async () => {
const userId = seeded.userIdFor(actor);
const projectId = projectIdFor(actor, seeded);
const expected = scenario.expected[actor];
const enriched = await fetchEnriched(projectId, userId);
// The pure half first: given the rows, this is the answer.
expect(computeProjectAccess(enriched, userId), 'computeProjectAccess').toEqual(expected);
// And the querying half, which has to arrive at the same place from the
// same rows, whatever the caller says it intends to do.
for (const intent of INTENTS) {
const checked = await checkProjectAccess(
enriched,
userId,
intent === undefined ? undefined : { intent }
);
expect(checked, `checkProjectAccess with intent ${intent ?? '(default)'}`).toEqual({
...expected,
...knownDivergence(actor, intent),
});
}
});
}
});
}
// ---------------------------------------------------------------------------
// The divergence, on its own
// ---------------------------------------------------------------------------
describe('checkProjectAccess and computeProjectAccess disagree in one place', () => {
it('hides the workspace role from a project owner who also owns the workspace, on intent view', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({
ownerId: owner.id,
workspaceId: workspace.id,
visibility: 'PRIVATE',
});
const enriched = await fetchEnriched(project.id, owner.id);
const computed = computeProjectAccess(enriched, owner.id);
const viewed = await checkProjectAccess(enriched, owner.id, { intent: 'view' });
const managed = await checkProjectAccess(enriched, owner.id, { intent: 'manage' });
// What the rows say: this user owns the workspace the project lives in.
expect(computed.isWorkspaceMember).toBe(true);
expect(computed.isWorkspaceAdmin).toBe(true);
// What checkProjectAccess() says on the intent that pages and GET routes
// use. `shouldLoadWorkspaceRole` skipped the workspace queries, so the role
// was never resolved and the two flags read as if this user were a stranger
// to the workspace.
expect(viewed.isWorkspaceMember).toBe(false);
expect(viewed.isWorkspaceAdmin).toBe(false);
// Ask the same question with a mutating intent and the same user, on the
// same rows, is a workspace owner again.
expect(managed.isWorkspaceMember).toBe(true);
expect(managed.isWorkspaceAdmin).toBe(true);
// Why nobody has noticed: every permission the flags feed is already
// granted by isOwner, so the divergence stops at the two identity flags.
expect(viewed.hasAccess).toBe(true);
expect(viewed.canEdit).toBe(true);
expect(viewed.canDelete).toBe(true);
expect({ ...viewed, isWorkspaceMember: true, isWorkspaceAdmin: true }).toEqual(computed);
});
});
+503
View File
@@ -0,0 +1,503 @@
// Exercises lib/r2-cleanup.ts, the module that decides which objects get
// removed from storage when a video, a project or a workspace is deleted.
//
// Everything here turns on one property: the module must only ever nominate an
// object that belongs to the entity being deleted. A widened `where` clause
// costs a customer their footage, and unlike a widened read it is not
// recoverable, so the first test in each collect* block is the negative one (a
// sibling's media is left alone) and the positive one comes second.
//
// tests/setup/api.ts stubs the named helpers in `@/lib/r2` but not `r2Client`,
// which is what deleteMediaFilesBestEffort() actually reaches for. The mock
// below replaces the client with a recorder, so a test can assert on the exact
// object keys the module chose. Nothing speaks S3.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
cleanupProjectMediaFiles,
cleanupVideoMediaFiles,
cleanupWorkspaceMediaFiles,
collectProjectMediaUrls,
collectVideoMediaUrls,
collectWorkspaceMediaUrls,
deleteMediaFilesBestEffort,
mediaUrlToKey,
} from '@/lib/r2-cleanup';
import {
createComment,
createProject,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
seedProject,
} from '../factories';
// vi.mock factories are hoisted above every const in the file, so the recorder
// and the bucket name have to be hoisted with them.
const r2 = vi.hoisted(() => ({
bucket: 'openframe-cleanup-test-bucket',
/** Every object key handed to a DeleteObjectCommand, in call order. */
deletedKeys: [] as string[],
/** Buckets seen alongside those keys. */
deletedBuckets: [] as string[],
/** Keys the fake client refuses, to drive the best-effort failure path. */
rejectKeys: new Set<string>(),
/** Commands that were not a DeleteObjectCommand, which would be a bug. */
otherCommands: [] as string[],
}));
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
const { DeleteObjectCommand: Delete } = await import('@aws-sdk/client-s3');
return {
...actual,
R2_BUCKET_NAME: r2.bucket,
r2Client: {
send: async (command: { input?: { Bucket?: string; Key?: string } }) => {
if (!(command instanceof Delete)) {
r2.otherCommands.push(command.constructor.name);
return {};
}
const key = command.input?.Key ?? '';
if (r2.rejectKeys.has(key)) {
throw new Error(`storage refused ${key}`);
}
r2.deletedKeys.push(key);
r2.deletedBuckets.push(command.input?.Bucket ?? '');
return {};
},
},
};
});
beforeEach(() => {
r2.deletedKeys.length = 0;
r2.deletedBuckets.length = 0;
r2.otherCommands.length = 0;
r2.rejectKeys.clear();
// Non-canonical URLs are reported through console.error by design; keep the
// suite output readable without hiding a genuine failure.
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
// Canonical proxy URLs and the storage keys they map to. Both sides are written
// out rather than derived, so a change to the prefix scheme fails here loudly.
const OWN_COMMENT_IMAGE = '/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png';
const OWN_COMMENT_IMAGE_KEY = 'images/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png';
const OWN_COMMENT_VOICE = '/api/upload/audio/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2.webm';
const OWN_COMMENT_VOICE_KEY = 'voice/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2.webm';
const OWN_ASSET_IMAGE = '/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3.png';
const OWN_ASSET_IMAGE_KEY = 'images/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3.png';
const OWN_VERSION_VIDEO = '/api/upload/video/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4.mp4';
const OWN_VERSION_VIDEO_KEY = 'videos/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4.mp4';
const OWN_VERSION_THUMB = '/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5.jpg';
// Media belonging to a live neighbour. None of these keys may ever appear in
// r2.deletedKeys when the neighbour is out of scope.
const OTHER_COMMENT_IMAGE = '/api/upload/image/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1.png';
const OTHER_COMMENT_IMAGE_KEY = 'images/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1.png';
const OTHER_ASSET_IMAGE = '/api/upload/image/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2.png';
const OTHER_VERSION_VIDEO = '/api/upload/video/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb3.mp4';
const OTHER_VERSION_VIDEO_KEY = 'videos/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb3.mp4';
/** A video with one r2 version carrying `videoUrl`, plus a commented image. */
async function seedMediaVideo(input: {
projectId: string;
ownerId: string;
videoUrl: string;
commentImageUrl: string;
assetUrl: string;
}) {
const video = await createVideo({ projectId: input.projectId });
const version = await createVersion({
videoParentId: video.id,
providerId: 'r2',
originalUrl: input.videoUrl,
});
await createComment({ versionId: version.id, imageUrl: input.commentImageUrl });
await createVideoAsset({
videoId: video.id,
billedUserId: input.ownerId,
provider: 'R2_IMAGE',
sourceUrl: input.assetUrl,
});
return { video, version };
}
describe('mediaUrlToKey', () => {
it.each([
[OWN_COMMENT_IMAGE, OWN_COMMENT_IMAGE_KEY],
[OWN_COMMENT_VOICE, OWN_COMMENT_VOICE_KEY],
[OWN_VERSION_VIDEO, OWN_VERSION_VIDEO_KEY],
])('maps the canonical proxy URL %s to %s', (url, key) => {
expect(mediaUrlToKey(url)).toBe(key);
});
// The regexes are anchored for exactly this reason: a key derived from a
// caller-supplied path is a key that can point at somebody else's object.
it.each([
['/api/upload/image/../../videos/live.mp4', 'a traversal segment'],
['/api/upload/audio/../images/live.png', 'a traversal segment in the audio branch'],
['/api/upload/video/../../etc/passwd', 'a traversal segment in the video branch'],
['https://evil.test/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png', 'a host'],
['/api/upload/image/not-a-uuid.png', 'a non-uuid basename'],
['/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'no extension'],
['/api/upload/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1.png?x=1', 'a query string'],
['/api/upload/image/', 'an empty basename'],
['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'an unrelated provider URL'],
['', 'an empty string'],
])('refuses to derive a key from %s (%s)', (url) => {
expect(mediaUrlToKey(url)).toBeNull();
});
});
describe('deleteMediaFilesBestEffort', () => {
it('deletes each distinct key once against the configured bucket', async () => {
const result = await deleteMediaFilesBestEffort([
OWN_COMMENT_IMAGE,
OWN_COMMENT_VOICE,
// The same URL twice: one DELETE is enough, and a second one is a wasted
// request against a key that no longer exists.
OWN_COMMENT_IMAGE,
]);
expect(r2.deletedKeys).toEqual([OWN_COMMENT_IMAGE_KEY, OWN_COMMENT_VOICE_KEY]);
expect(new Set(r2.deletedBuckets)).toEqual(new Set([r2.bucket]));
expect(r2.otherCommands).toEqual([]);
expect(result).toEqual({ attempted: 2, failed: 0, failedKeys: [] });
});
it('skips non-canonical URLs and does not count them as attempted', async () => {
const result = await deleteMediaFilesBestEffort([
OWN_COMMENT_IMAGE,
'/api/upload/image/../../videos/live.mp4',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
]);
expect(r2.deletedKeys).toEqual([OWN_COMMENT_IMAGE_KEY]);
expect(result.attempted).toBe(1);
expect(result.failed).toBe(0);
});
// The caller turns this into a warning on the response rather than a 500, so
// one dead object must not abort the rest of the sweep.
it('reports a refused key and still deletes the others', async () => {
r2.rejectKeys.add(OWN_COMMENT_IMAGE_KEY);
const result = await deleteMediaFilesBestEffort([
OWN_COMMENT_IMAGE,
OWN_COMMENT_VOICE,
OWN_VERSION_VIDEO,
]);
expect(r2.deletedKeys).toEqual([OWN_COMMENT_VOICE_KEY, OWN_VERSION_VIDEO_KEY]);
expect(result).toEqual({
attempted: 3,
failed: 1,
failedKeys: [OWN_COMMENT_IMAGE_KEY],
});
});
it('does nothing for an empty list', async () => {
const result = await deleteMediaFilesBestEffort([]);
expect(r2.deletedKeys).toEqual([]);
expect(result).toEqual({ attempted: 0, failed: 0, failedKeys: [] });
});
});
describe('collectVideoMediaUrls', () => {
// The load-bearing test of this file. A neighbouring video in the same
// project is live; none of its media may be nominated for deletion.
it('leaves the media of another video in the same project alone', async () => {
const scenario = await seedProject();
const { video } = await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OWN_VERSION_VIDEO,
commentImageUrl: OWN_COMMENT_IMAGE,
assetUrl: OWN_ASSET_IMAGE,
});
await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OTHER_VERSION_VIDEO,
commentImageUrl: OTHER_COMMENT_IMAGE,
assetUrl: OTHER_ASSET_IMAGE,
});
const urls = await collectVideoMediaUrls(video.id);
expect(new Set(urls)).toEqual(new Set([OWN_VERSION_VIDEO, OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE]));
expect(urls).not.toContain(OTHER_VERSION_VIDEO);
expect(urls).not.toContain(OTHER_COMMENT_IMAGE);
expect(urls).not.toContain(OTHER_ASSET_IMAGE);
});
it('collects comment voice and image URLs, R2 image assets and r2 version media', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
const version = await createVersion({
videoParentId: video.id,
providerId: 'r2',
originalUrl: OWN_VERSION_VIDEO,
thumbnailUrl: OWN_VERSION_THUMB,
});
await createComment({
versionId: version.id,
imageUrl: OWN_COMMENT_IMAGE,
voiceUrl: OWN_COMMENT_VOICE,
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'R2_IMAGE',
sourceUrl: OWN_ASSET_IMAGE,
});
const urls = await collectVideoMediaUrls(video.id);
expect(new Set(urls)).toEqual(
new Set([
OWN_VERSION_VIDEO,
OWN_VERSION_THUMB,
OWN_COMMENT_IMAGE,
OWN_COMMENT_VOICE,
OWN_ASSET_IMAGE,
])
);
});
// A youtube or bunny version's originalUrl is not an object this deployment
// owns, and a BUNNY asset is cleaned up through the Bunny API instead.
it('ignores versions from other providers and assets that are not R2 images', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
versionNumber: 1,
providerId: 'youtube',
originalUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/hq.jpg',
});
await createVersion({
videoParentId: video.id,
versionNumber: 2,
providerId: 'r2',
originalUrl: OWN_VERSION_VIDEO,
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'BUNNY',
providerVideoId: 'bunny-asset-1',
sourceUrl: OTHER_ASSET_IMAGE,
});
const urls = await collectVideoMediaUrls(video.id);
expect(urls).toEqual([OWN_VERSION_VIDEO]);
});
it('returns nothing for a video whose only version is hosted elsewhere', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'youtube',
originalUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
});
expect(await collectVideoMediaUrls(video.id)).toEqual([]);
});
});
describe('collectProjectMediaUrls', () => {
it('leaves the media of another project in the same workspace alone', async () => {
const scenario = await seedProject();
const sibling = await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OWN_VERSION_VIDEO,
commentImageUrl: OWN_COMMENT_IMAGE,
assetUrl: OWN_ASSET_IMAGE,
});
await seedMediaVideo({
projectId: sibling.id,
ownerId: scenario.owner.id,
videoUrl: OTHER_VERSION_VIDEO,
commentImageUrl: OTHER_COMMENT_IMAGE,
assetUrl: OTHER_ASSET_IMAGE,
});
const urls = await collectProjectMediaUrls(scenario.project.id);
expect(new Set(urls)).toEqual(new Set([OWN_VERSION_VIDEO, OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE]));
});
it('collects across every video in the project', async () => {
const scenario = await seedProject();
await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OWN_VERSION_VIDEO,
commentImageUrl: OWN_COMMENT_IMAGE,
assetUrl: OWN_ASSET_IMAGE,
});
await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OTHER_VERSION_VIDEO,
commentImageUrl: OTHER_COMMENT_IMAGE,
assetUrl: OTHER_ASSET_IMAGE,
});
const urls = await collectProjectMediaUrls(scenario.project.id);
expect(urls).toHaveLength(6);
expect(new Set(urls)).toEqual(
new Set([
OWN_VERSION_VIDEO,
OWN_COMMENT_IMAGE,
OWN_ASSET_IMAGE,
OTHER_VERSION_VIDEO,
OTHER_COMMENT_IMAGE,
OTHER_ASSET_IMAGE,
])
);
});
});
describe('collectWorkspaceMediaUrls', () => {
it('leaves the media of another workspace alone', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
// Same owner, different workspace: the scoping has to come from the join,
// not from who happens to be billed.
const otherWorkspace = await createWorkspace({ ownerId: owner.id });
const otherProject = await createProject({
ownerId: owner.id,
workspaceId: otherWorkspace.id,
});
await seedMediaVideo({
projectId: project.id,
ownerId: owner.id,
videoUrl: OWN_VERSION_VIDEO,
commentImageUrl: OWN_COMMENT_IMAGE,
assetUrl: OWN_ASSET_IMAGE,
});
await seedMediaVideo({
projectId: otherProject.id,
ownerId: owner.id,
videoUrl: OTHER_VERSION_VIDEO,
commentImageUrl: OTHER_COMMENT_IMAGE,
assetUrl: OTHER_ASSET_IMAGE,
});
const urls = await collectWorkspaceMediaUrls(workspace.id);
expect(new Set(urls)).toEqual(new Set([OWN_VERSION_VIDEO, OWN_COMMENT_IMAGE, OWN_ASSET_IMAGE]));
});
});
// The three cleanup* wrappers are what the delete routes call, so they are the
// place to assert on keys rather than URLs: this is the last hop before an
// object stops existing.
describe('cleanupVideoMediaFiles', () => {
it('deletes only the target video objects and leaves the sibling video objects in storage', async () => {
const scenario = await seedProject();
const { video } = await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OWN_VERSION_VIDEO,
commentImageUrl: OWN_COMMENT_IMAGE,
assetUrl: OWN_ASSET_IMAGE,
});
await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OTHER_VERSION_VIDEO,
commentImageUrl: OTHER_COMMENT_IMAGE,
assetUrl: OTHER_ASSET_IMAGE,
});
await cleanupVideoMediaFiles(video.id);
expect(new Set(r2.deletedKeys)).toEqual(
new Set([OWN_VERSION_VIDEO_KEY, OWN_COMMENT_IMAGE_KEY, OWN_ASSET_IMAGE_KEY])
);
expect(r2.deletedKeys).not.toContain(OTHER_VERSION_VIDEO_KEY);
expect(r2.deletedKeys).not.toContain(OTHER_COMMENT_IMAGE_KEY);
});
});
describe('cleanupProjectMediaFiles', () => {
it('deletes only the target project objects', async () => {
const scenario = await seedProject();
const sibling = await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
await seedMediaVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: OWN_VERSION_VIDEO,
commentImageUrl: OWN_COMMENT_IMAGE,
assetUrl: OWN_ASSET_IMAGE,
});
await seedMediaVideo({
projectId: sibling.id,
ownerId: scenario.owner.id,
videoUrl: OTHER_VERSION_VIDEO,
commentImageUrl: OTHER_COMMENT_IMAGE,
assetUrl: OTHER_ASSET_IMAGE,
});
await cleanupProjectMediaFiles(scenario.project.id);
expect(new Set(r2.deletedKeys)).toEqual(
new Set([OWN_VERSION_VIDEO_KEY, OWN_COMMENT_IMAGE_KEY, OWN_ASSET_IMAGE_KEY])
);
});
});
describe('cleanupWorkspaceMediaFiles', () => {
it('deletes only the target workspace objects', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
const otherWorkspace = await createWorkspace({ ownerId: owner.id });
const otherProject = await createProject({
ownerId: owner.id,
workspaceId: otherWorkspace.id,
});
await seedMediaVideo({
projectId: project.id,
ownerId: owner.id,
videoUrl: OWN_VERSION_VIDEO,
commentImageUrl: OWN_COMMENT_IMAGE,
assetUrl: OWN_ASSET_IMAGE,
});
await seedMediaVideo({
projectId: otherProject.id,
ownerId: owner.id,
videoUrl: OTHER_VERSION_VIDEO,
commentImageUrl: OTHER_COMMENT_IMAGE,
assetUrl: OTHER_ASSET_IMAGE,
});
await cleanupWorkspaceMediaFiles(workspace.id);
expect(new Set(r2.deletedKeys)).toEqual(
new Set([OWN_VERSION_VIDEO_KEY, OWN_COMMENT_IMAGE_KEY, OWN_ASSET_IMAGE_KEY])
);
expect(r2.deletedKeys).not.toContain(OTHER_VERSION_VIDEO_KEY);
});
});
+220
View File
@@ -0,0 +1,220 @@
// Exercises lib/r2-upload-session.ts, the bookkeeping either side of a direct
// upload.
//
// Small module, but the `where` clause on the cancel is load-bearing in two
// directions: it must not let a caller cancel a session that is not theirs to
// cancel, and it must actually match the session they do own, because the
// r2-init DELETE route releases the quota reservation only when the update
// reports a row. A cancel that quietly matches nothing leaves the reservation
// pinned for its whole TTL.
import { describe, expect, it } from 'vitest';
import { randomUUID } from 'crypto';
import { db } from '@/lib/db';
import { cancelR2UploadSession, createR2UploadSession } from '@/lib/r2-upload-session';
import { seedProject } from '../factories';
const HOUR_MS = 60 * 60 * 1000;
async function newSession(
overrides: { expiresAt?: Date; multipartUploadId?: string | null; reservationId?: string } = {}
) {
const scenario = await seedProject();
const fileId = randomUUID();
const session = await createR2UploadSession({
userId: scenario.owner.id,
projectId: scenario.project.id,
billedUserId: scenario.owner.id,
objectKey: `videos/${fileId}.mp4`,
thumbnailObjectKey: `images/${fileId}.jpg`,
declaredSizeBytes: BigInt(4096),
contentType: 'video/mp4',
reservationId: overrides.reservationId ?? null,
uploadJti: randomUUID(),
expiresAt: overrides.expiresAt ?? new Date(Date.now() + HOUR_MS),
...(overrides.multipartUploadId === undefined
? {}
: { multipartUploadId: overrides.multipartUploadId }),
});
return { scenario, session, fileId };
}
describe('createR2UploadSession', () => {
it('writes an INITIATED row carrying every field the finalizer reads back', async () => {
const scenario = await seedProject();
const fileId = randomUUID();
const uploadJti = randomUUID();
const expiresAt = new Date(Date.now() + HOUR_MS);
const created = await createR2UploadSession({
userId: scenario.owner.id,
projectId: scenario.project.id,
billedUserId: scenario.owner.id,
objectKey: `videos/${fileId}.mp4`,
thumbnailObjectKey: `images/${fileId}.jpg`,
declaredSizeBytes: BigInt(123_456),
contentType: 'video/webm',
reservationId: null,
uploadJti,
expiresAt,
});
const row = await db.videoUploadSession.findUniqueOrThrow({ where: { id: created.id } });
expect(row.status).toBe('INITIATED');
expect(row.userId).toBe(scenario.owner.id);
expect(row.projectId).toBe(scenario.project.id);
expect(row.billedUserId).toBe(scenario.owner.id);
expect(row.objectKey).toBe(`videos/${fileId}.mp4`);
expect(row.thumbnailObjectKey).toBe(`images/${fileId}.jpg`);
expect(row.declaredSizeBytes).toBe(BigInt(123_456));
expect(row.contentType).toBe('video/webm');
expect(row.uploadJti).toBe(uploadJti);
expect(row.expiresAt.getTime()).toBe(expiresAt.getTime());
expect(row.reservationId).toBeNull();
expect(row.consumedAt).toBeNull();
});
// The field is optional on the input but the column is not nullable-by-
// accident: a single-shot PUT must store null rather than undefined, because
// the complete route branches on it to decide whether to assemble parts.
it('stores a null multipart id when none is supplied', async () => {
const { session } = await newSession();
expect(session.multipartUploadId).toBeNull();
});
it('stores an explicit null multipart id as null', async () => {
const { session } = await newSession({ multipartUploadId: null });
expect(session.multipartUploadId).toBeNull();
});
it('records the multipart upload id when the upload is chunked', async () => {
const { session } = await newSession({ multipartUploadId: 'multipart-upload-id-1' });
expect(session.multipartUploadId).toBe('multipart-upload-id-1');
});
it('links the quota reservation the caller already took', async () => {
const scenario = await seedProject();
const reservation = await db.uploadReservation.create({
data: {
billedUserId: scenario.owner.id,
sizeBytes: BigInt(4096),
expiresAt: new Date(Date.now() + HOUR_MS),
},
});
const fileId = randomUUID();
const created = await createR2UploadSession({
userId: scenario.owner.id,
projectId: scenario.project.id,
billedUserId: scenario.owner.id,
objectKey: `videos/${fileId}.mp4`,
thumbnailObjectKey: `images/${fileId}.jpg`,
declaredSizeBytes: BigInt(4096),
contentType: 'video/mp4',
reservationId: reservation.id,
uploadJti: randomUUID(),
expiresAt: new Date(Date.now() + HOUR_MS),
});
expect(created.reservationId).toBe(reservation.id);
});
// objectKey is unique in the schema, which is what stops two sessions from
// ever pointing at the same object and racing each other's cleanup.
it('refuses a second session for the same object key', async () => {
const { scenario, fileId } = await newSession();
await expect(
createR2UploadSession({
userId: scenario.owner.id,
projectId: scenario.project.id,
billedUserId: scenario.owner.id,
objectKey: `videos/${fileId}.mp4`,
thumbnailObjectKey: `images/${fileId}.jpg`,
declaredSizeBytes: BigInt(4096),
contentType: 'video/mp4',
reservationId: null,
uploadJti: randomUUID(),
expiresAt: new Date(Date.now() + HOUR_MS),
})
).rejects.toThrow();
});
});
describe('cancelR2UploadSession', () => {
it('flips an INITIATED session to CANCELLED and stamps consumedAt', async () => {
const { session } = await newSession();
const result = await cancelR2UploadSession(session.id);
expect(result.count).toBe(1);
const row = await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } });
expect(row.status).toBe('CANCELLED');
expect(row.consumedAt).toBeInstanceOf(Date);
});
it('matches nothing on a second cancel, so the route cannot double-release', async () => {
const { session } = await newSession();
await cancelR2UploadSession(session.id);
const result = await cancelR2UploadSession(session.id);
expect(result.count).toBe(0);
});
it('refuses to cancel a session that was already finalized', async () => {
const { session } = await newSession();
await db.videoUploadSession.update({
where: { id: session.id },
data: { status: 'FINALIZED' },
});
const result = await cancelR2UploadSession(session.id);
expect(result.count).toBe(0);
expect(
(await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } })).status
).toBe('FINALIZED');
});
// The `expiresAt: { gt: now }` clause means an expired session cannot be
// cancelled at all: the row stays INITIATED and consumedAt stays null. That
// is the current contract, and it is why the sweeper rather than the route
// has to be the thing that reclaims those reservations. See the report.
it('matches nothing once the session has expired, leaving it INITIATED', async () => {
const { session } = await newSession({ expiresAt: new Date(Date.now() - 60_000) });
const result = await cancelR2UploadSession(session.id);
expect(result.count).toBe(0);
const row = await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } });
expect(row.status).toBe('INITIATED');
expect(row.consumedAt).toBeNull();
});
it('does nothing for an id that matches no row', async () => {
const { session } = await newSession();
const result = await cancelR2UploadSession('no-such-session');
expect(result.count).toBe(0);
expect(
(await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } })).status
).toBe('INITIATED');
});
it('leaves every other session alone', async () => {
const target = await newSession();
const bystander = await newSession();
await cancelR2UploadSession(target.session.id);
expect(
(await db.videoUploadSession.findUniqueOrThrow({ where: { id: bystander.session.id } }))
.status
).toBe('INITIATED');
});
});
+555
View File
@@ -0,0 +1,555 @@
// Exercises lib/r2-video-finalize.ts, the gate between "the browser says it
// finished uploading" and "a row is written that bills the user for it".
//
// Everything the caller supplies is hostile until proven otherwise: the object
// key, the proxy URL and the upload token all arrive in the request body. The
// module checks them against each other and then against the session row, and
// only then trusts storage. Each of those checks gets a test, because any one
// of them going missing turns the endpoint into "tell me a size and I will
// believe you".
//
// The failure branches matter as much as the happy path: when the object is
// missing, oversized or not a video, the session is cancelled and both objects
// are removed. Leaving an INITIATED session behind would pin the quota
// reservation for its whole TTL.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { randomUUID } from 'crypto';
import { db } from '@/lib/db';
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
import { createR2UploadToken } from '@/lib/r2-upload-token';
import { createR2UploadSession } from '@/lib/r2-upload-session';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { createUser, seedProject } from '../factories';
/** 12 bytes whose 5th to 8th spell `ftyp`, the ISO base media signature. */
function mp4Header(): Uint8Array {
const bytes = new Uint8Array(12);
bytes.set([0x66, 0x74, 0x79, 0x70], 4);
return bytes;
}
function bytesOf(...values: number[]): Uint8Array {
return Uint8Array.from(values);
}
/** Makes storage report an object of `sizeBytes` whose header is `header`. */
function storageHolds(sizeBytes: number, header: Uint8Array = mp4Header()): void {
vi.mocked(headVideoObject).mockResolvedValue({
contentLength: BigInt(sizeBytes),
contentType: 'video/mp4',
});
vi.mocked(readVideoObjectBytes).mockResolvedValue(header);
}
interface SeededUpload {
userId: string;
projectId: string;
sessionId: string;
objectKey: string;
thumbnailObjectKey: string;
proxyUrl: string;
uploadToken: string;
reservationId: string | null;
billedUserId: string;
}
/**
* An INITIATED session plus the token the r2-init route would have handed back
* for it, both built through the production helpers rather than by hand.
*/
async function seedUpload(
overrides: {
declaredSizeBytes?: bigint;
expiresAt?: Date;
thumbnailObjectKey?: string;
reservationId?: string | null;
billedUserId?: string;
userId?: string;
projectId?: string;
tokenUserId?: string;
tokenProjectId?: string;
objectKey?: string;
} = {}
): Promise<SeededUpload & { scenarioOwnerId: string }> {
const scenario = await seedProject();
const fileId = randomUUID();
const objectKey = overrides.objectKey ?? `videos/${fileId}.mp4`;
const thumbnailObjectKey = overrides.thumbnailObjectKey ?? `images/${fileId}.jpg`;
const uploadJti = randomUUID();
const userId = overrides.userId ?? scenario.owner.id;
const projectId = overrides.projectId ?? scenario.project.id;
const billedUserId = overrides.billedUserId ?? scenario.owner.id;
const session = await createR2UploadSession({
userId,
projectId,
billedUserId,
objectKey,
thumbnailObjectKey,
declaredSizeBytes: overrides.declaredSizeBytes ?? BigInt(4096),
contentType: 'video/mp4',
reservationId: overrides.reservationId ?? null,
uploadJti,
expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000),
});
const uploadToken = createR2UploadToken({
userId: overrides.tokenUserId ?? userId,
projectId: overrides.tokenProjectId ?? projectId,
objectKey,
sessionId: session.id,
tokenId: uploadJti,
thumbnailObjectKey,
});
return {
userId,
projectId,
sessionId: session.id,
objectKey,
thumbnailObjectKey,
proxyUrl: `/api/upload/video/${fileId}.mp4`,
uploadToken,
reservationId: session.reservationId,
billedUserId,
scenarioOwnerId: scenario.owner.id,
};
}
function finalize(
upload: SeededUpload,
overrides: Partial<Parameters<typeof finalizeR2VideoUpload>[0]> = {}
) {
return finalizeR2VideoUpload({
userId: upload.userId,
projectId: upload.projectId,
videoUrl: upload.proxyUrl,
objectKey: upload.objectKey,
uploadToken: upload.uploadToken,
...overrides,
});
}
beforeEach(() => {
// tests/setup/api.ts already stubs both with the production shapes, but this
// suite is the one that varies them per test, so it sets its own baseline
// rather than depending on the shared default staying at 1024 bytes.
storageHolds(1024);
vi.mocked(deleteVideoObject).mockClear();
vi.mocked(deleteR2Object).mockClear();
});
describe('finalizeR2VideoUpload request shape', () => {
it('rejects a missing objectKey', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { objectKey: '' });
expect(result).toEqual({
ok: false,
error: 'R2 uploads must include objectKey and uploadToken',
status: 400,
});
});
it('rejects a missing uploadToken', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { uploadToken: '' });
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(400);
});
it('rejects an object key outside the videos prefix', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { objectKey: 'images/not-a-video.jpg' });
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
});
it('rejects an object key whose basename is not a uuid', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { objectKey: 'videos/../../etc/passwd' });
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
});
// The proxy URL is what gets written into VideoVersion.originalUrl, so it has
// to name the same object the token authorises. Otherwise a caller could
// upload to their own key and point the row at somebody else's file.
it('rejects a video URL that does not match the object key', async () => {
const upload = await seedUpload();
const result = await finalize(upload, {
videoUrl: '/api/upload/video/99999999-9999-4999-8999-999999999999.mp4',
});
expect(result).toEqual({
ok: false,
error: 'Video URL does not match the uploaded object',
status: 400,
});
});
it('rejects a video URL that is not a proxy path at all', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { videoUrl: 'https://cdn.evil.test/clip.mp4' });
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(400);
});
});
describe('finalizeR2VideoUpload token verification', () => {
it('rejects a token that is not even parseable', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { uploadToken: 'forged.token' });
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
it('rejects a token whose signature has been tampered with', async () => {
const upload = await seedUpload();
const [payload] = upload.uploadToken.split('.');
const result = await finalize(upload, { uploadToken: `${payload}.notthesignature` });
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(403);
});
// A validly signed token issued to somebody else. The signature checks out,
// so only the subject comparison stands between the two accounts.
it('rejects a well-formed token issued to a different user', async () => {
const upload = await seedUpload({ tokenUserId: 'someone-else-entirely' });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
it('rejects a well-formed token issued for a different project', async () => {
const upload = await seedUpload({ tokenProjectId: 'some-other-project' });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
it('rejects a valid token presented by a different caller', async () => {
const upload = await seedUpload();
const impostor = await createUser();
const result = await finalize(upload, { userId: impostor.id });
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
});
describe('finalizeR2VideoUpload session lookup', () => {
it('rejects a session that has already been cancelled', async () => {
const upload = await seedUpload();
await db.videoUploadSession.update({
where: { id: upload.sessionId },
data: { status: 'CANCELLED' },
});
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
// Replay protection: a session that already produced a video row must not
// produce a second one from the same token.
it('rejects a session that has already been finalized', async () => {
const upload = await seedUpload();
await db.videoUploadSession.update({
where: { id: upload.sessionId },
data: { status: 'FINALIZED' },
});
const result = await finalize(upload);
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(403);
});
it('rejects a session whose expiry has passed', async () => {
const upload = await seedUpload({ expiresAt: new Date(Date.now() - 60 * 1000) });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
// The thumbnail key is carried in the token and re-read off the session row.
// A key outside images/ would make the derived proxy URL point nowhere, and
// the module refuses rather than emitting a half-formed URL.
it('rejects a session whose thumbnail key is not under the images prefix', async () => {
const upload = await seedUpload({ thumbnailObjectKey: 'thumbs/elsewhere.jpg' });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
});
describe('finalizeR2VideoUpload storage checks', () => {
/** Reads the session row back after a call that should have cancelled it. */
async function sessionOf(sessionId: string) {
return db.videoUploadSession.findUniqueOrThrow({ where: { id: sessionId } });
}
it('cancels the session and removes both objects when storage has no object', async () => {
const upload = await seedUpload();
vi.mocked(headVideoObject).mockResolvedValue(null);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded video was not found in storage',
status: 400,
});
const session = await sessionOf(upload.sessionId);
expect(session.status).toBe('CANCELLED');
expect(session.consumedAt).toBeInstanceOf(Date);
expect(vi.mocked(deleteVideoObject)).toHaveBeenCalledWith(upload.objectKey);
expect(vi.mocked(deleteR2Object)).toHaveBeenCalledWith(upload.thumbnailObjectKey);
});
it('refuses an object key outside the videos/ prefix without asking storage', async () => {
// Two things at once, and the second is the reason the stubs are reset.
//
// The claim is that finalize refuses a key it has no business finalising
// *before* it reaches out to storage, so the refusal cannot depend on what
// the bucket happens to answer. Proving "before" means the storage stubs
// must not be able to rescue it: this file's beforeEach calls
// storageHolds(), which mockResolvedValues both functions for any key, so
// mockReset() puts back the prefix-aware implementations from
// tests/setup/api.ts (mockReset restores the implementation vi.fn() was
// created with). Neither is called at all, which is the point.
vi.mocked(headVideoObject).mockReset();
vi.mocked(readVideoObjectBytes).mockReset();
const upload = await seedUpload({ objectKey: `images/${randomUUID()}.mp4` });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
expect(vi.mocked(headVideoObject)).not.toHaveBeenCalled();
expect(vi.mocked(readVideoObjectBytes)).not.toHaveBeenCalled();
// Refused, not cancelled: nothing was uploaded under a key this session
// could own, so there is nothing to clean up and the session is left alone.
expect((await sessionOf(upload.sessionId)).status).toBe('INITIATED');
});
it('cancels when storage reports a zero-byte object', async () => {
const upload = await seedUpload();
vi.mocked(headVideoObject).mockResolvedValue({
contentLength: BigInt(0),
contentType: 'video/mp4',
});
const result = await finalize(upload);
expect(result.ok === false && result.error).toBe('Uploaded video was not found in storage');
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
it('cancels when the stored object is over the configured maximum', async () => {
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '2048');
const upload = await seedUpload({ declaredSizeBytes: BigInt(1_000_000) });
storageHolds(4096);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded video exceeds the maximum allowed upload size',
status: 400,
});
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
// The declared size is what the quota reservation was sized against. An
// object bigger than that has been billed for less than it costs.
it('cancels when the stored object is larger than the declared size', async () => {
const upload = await seedUpload({ declaredSizeBytes: BigInt(1024) });
storageHolds(1025);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded video size does not match upload request',
status: 400,
});
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
it('accepts an object exactly the declared size', async () => {
const upload = await seedUpload({ declaredSizeBytes: BigInt(1024) });
storageHolds(1024);
const result = await finalize(upload);
expect(result.ok).toBe(true);
});
it('accepts an object smaller than declared, because compression is allowed to win', async () => {
const upload = await seedUpload({ declaredSizeBytes: BigInt(4096) });
storageHolds(10);
const result = await finalize(upload);
expect(result.ok).toBe(true);
expect(result.ok === true && result.sizeBytes).toBe(BigInt(10));
});
it('cancels when the header bytes cannot be read at all', async () => {
const upload = await seedUpload();
vi.mocked(readVideoObjectBytes).mockResolvedValue(null);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded file is not a valid video',
status: 400,
});
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
// Content-type is caller-controlled, so the first 64 bytes are the only thing
// that decides whether this is really a video. A renamed .exe stops here.
it('cancels when the header bytes are not a known container', async () => {
const upload = await seedUpload();
storageHolds(1024, bytesOf(0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0, 0, 0, 0));
const result = await finalize(upload);
expect(result.ok === false && result.error).toBe('Uploaded file is not a valid video');
expect(vi.mocked(deleteVideoObject)).toHaveBeenCalledWith(upload.objectKey);
});
it('cancels when the object is too short to carry a signature', async () => {
const upload = await seedUpload();
storageHolds(1024, bytesOf(0x66, 0x74, 0x79));
const result = await finalize(upload);
expect(result.ok === false && result.error).toBe('Uploaded file is not a valid video');
});
it.each([
['an mp4 ftyp box', mp4Header()],
['a matroska EBML header', bytesOf(0x1a, 0x45, 0xdf, 0xa3)],
['an Ogg page header', bytesOf(0x4f, 0x67, 0x67, 0x53)],
['a RIFF AVI header', bytesOf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x41, 0x56, 0x49, 0x20)],
])('accepts %s', async (_label, header) => {
const upload = await seedUpload();
storageHolds(1024, header);
expect((await finalize(upload)).ok).toBe(true);
});
// A RIFF container that is not AVI (a .wav, say) has the same first four
// bytes and must not slip through on the prefix alone.
it('cancels a RIFF container that is not AVI', async () => {
const upload = await seedUpload();
storageHolds(1024, bytesOf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45));
expect((await finalize(upload)).ok).toBe(false);
});
});
describe('finalizeR2VideoUpload success', () => {
it('returns the proxy URLs, the session and the billing subject', async () => {
const billed = await createUser();
const upload = await seedUpload({ declaredSizeBytes: BigInt(8192), billedUserId: billed.id });
storageHolds(7000);
const result = await finalize(upload);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.sizeBytes).toBe(BigInt(7000));
expect(result.objectKey).toBe(upload.objectKey);
expect(result.proxyUrl).toBe(upload.proxyUrl);
expect(result.sessionId).toBe(upload.sessionId);
expect(result.reservationId).toBeNull();
expect(result.billedUserId).toBe(billed.id);
expect(result.thumbnailObjectKey).toBe(upload.thumbnailObjectKey);
expect(result.thumbnailProxyUrl).toBe(
`/api/upload/image/${upload.thumbnailObjectKey.slice('images/'.length)}`
);
});
// Finalisation validates; it does not consume. The caller writes the row and
// then marks the session FINALIZED, so a success here must leave the session
// exactly as it found it.
it('leaves the session INITIATED and deletes nothing', async () => {
const upload = await seedUpload();
const result = await finalize(upload);
expect(result.ok).toBe(true);
const session = await db.videoUploadSession.findUniqueOrThrow({
where: { id: upload.sessionId },
});
expect(session.status).toBe('INITIATED');
expect(session.consumedAt).toBeNull();
expect(vi.mocked(deleteVideoObject)).not.toHaveBeenCalled();
expect(vi.mocked(deleteR2Object)).not.toHaveBeenCalled();
});
it('carries the reservation id through so the caller can release it', async () => {
const scenario = await seedProject();
const reservation = await db.uploadReservation.create({
data: {
billedUserId: scenario.owner.id,
sizeBytes: BigInt(4096),
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
},
});
const fileId = randomUUID();
const objectKey = `videos/${fileId}.mp4`;
const thumbnailObjectKey = `images/${fileId}.jpg`;
const uploadJti = randomUUID();
const session = await createR2UploadSession({
userId: scenario.owner.id,
projectId: scenario.project.id,
billedUserId: scenario.owner.id,
objectKey,
thumbnailObjectKey,
declaredSizeBytes: BigInt(4096),
contentType: 'video/mp4',
reservationId: reservation.id,
uploadJti,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const result = await finalizeR2VideoUpload({
userId: scenario.owner.id,
projectId: scenario.project.id,
videoUrl: `/api/upload/video/${fileId}.mp4`,
objectKey,
uploadToken: createR2UploadToken({
userId: scenario.owner.id,
projectId: scenario.project.id,
objectKey,
sessionId: session.id,
tokenId: uploadJti,
thumbnailObjectKey,
}),
});
expect(result.ok === true && result.reservationId).toBe(reservation.id);
});
});
+570
View File
@@ -0,0 +1,570 @@
// Exercises lib/video-assets.ts directly.
//
// Two API suites (assets-authz, download-authz) already drive
// getVideoAssetAccessContext() through routes, but only ever at the granularity
// of a status code. That leaves the flags it computes indistinguishable from
// one another: a context that set all four booleans to `hasViewAccess` would
// pass every one of those tests. This file asserts on the flags themselves, and
// on the pure helpers around them that nothing else covers at all.
import { describe, expect, it } from 'vitest';
import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session';
import {
canDeleteAssetForViewer,
extractAudioFileNameFromProxyUrl,
extractAudioKeyFromProxyUrl,
extractImageFileNameFromProxyUrl,
extractImageKeyFromProxyUrl,
extractVideoFileNameFromProxyUrl,
extractVideoKeyFromProxyUrl,
getVideoAssetAccessContext,
mediaUrlToR2Key,
sanitizeAssetDisplayName,
SAFE_BUNNY_VIDEO_ID,
} from '@/lib/video-assets';
import { apiRequest } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createShareLink,
createUser,
createVideo,
seedProject,
} from '../factories';
const IMAGE_URL = '/api/upload/image/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png';
const AUDIO_URL = '/api/upload/audio/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm';
const VIDEO_URL = '/api/upload/video/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3.mp4';
describe('sanitizeAssetDisplayName', () => {
it('keeps an ordinary name unchanged', () => {
expect(sanitizeAssetDisplayName('B-roll take 2.mp4', 'fallback')).toBe('B-roll take 2.mp4');
});
// Brackets and parentheses are stripped because the name is interpolated into
// Markdown-ish notification bodies, where they would change the rendering.
it('strips brackets and parentheses', () => {
expect(sanitizeAssetDisplayName('[click](http://evil.test) shot', 'fallback')).toBe(
'clickhttp://evil.test shot'
);
});
// Control characters are stripped before whitespace is collapsed, so a
// newline leaves no gap behind where it used to be.
it('strips control characters', () => {
expect(sanitizeAssetDisplayName('take\u00001\u007Fsecond\nthird', 'fallback')).toBe(
'take1secondthird'
);
});
it('collapses runs of whitespace and trims the ends', () => {
expect(sanitizeAssetDisplayName(' take two \t three ', 'fallback')).toBe(
'take two three'
);
});
it.each<[string | null | undefined, string]>([
[null, 'a null value'],
[undefined, 'an undefined value'],
['', 'an empty string'],
[' ', 'only whitespace'],
['[]()', 'only stripped characters'],
[42 as unknown as string, 'a non-string value'],
])('falls back for %s (%s)', (value) => {
expect(sanitizeAssetDisplayName(value, 'Comment Image')).toBe('Comment Image');
});
it('truncates at 200 characters', () => {
const result = sanitizeAssetDisplayName('x'.repeat(500), 'fallback');
expect(result).toHaveLength(200);
expect(result).toBe('x'.repeat(200));
});
});
describe('proxy URL extraction', () => {
it('derives the image key and file name from a canonical image URL', () => {
expect(extractImageKeyFromProxyUrl(IMAGE_URL)).toBe(
'images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'
);
expect(extractImageFileNameFromProxyUrl(IMAGE_URL)).toBe(
'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'
);
});
it('derives the audio key and file name from a canonical audio URL', () => {
expect(extractAudioKeyFromProxyUrl(AUDIO_URL)).toBe(
'voice/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm'
);
expect(extractAudioFileNameFromProxyUrl(AUDIO_URL)).toBe(
'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm'
);
});
it('derives the video key and file name from a canonical video URL', () => {
expect(extractVideoKeyFromProxyUrl(VIDEO_URL)).toBe(
'videos/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3.mp4'
);
expect(extractVideoFileNameFromProxyUrl(VIDEO_URL)).toBe(
'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3.mp4'
);
});
// Each extractor is anchored on its own prefix, so a URL of one media type
// must not resolve through another type's extractor.
it('refuses a URL from a different media prefix', () => {
expect(extractImageKeyFromProxyUrl(AUDIO_URL)).toBeNull();
expect(extractAudioKeyFromProxyUrl(VIDEO_URL)).toBeNull();
expect(extractVideoKeyFromProxyUrl(IMAGE_URL)).toBeNull();
});
it.each([
['/api/upload/image/../../videos/live.mp4', 'a traversal segment'],
['https://evil.test/api/upload/image/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png', 'a host'],
['/api/upload/image/not-a-uuid.png', 'a non-uuid basename'],
['/api/upload/image/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png/extra', 'a trailing segment'],
['', 'an empty string'],
])('refuses to derive an image key from %s (%s)', (url) => {
expect(extractImageKeyFromProxyUrl(url)).toBeNull();
expect(extractImageFileNameFromProxyUrl(url)).toBeNull();
});
});
describe('mediaUrlToR2Key', () => {
it('derives an image key and a voice key from canonical URLs', () => {
expect(mediaUrlToR2Key(IMAGE_URL)).toBe('images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png');
expect(mediaUrlToR2Key(AUDIO_URL)).toBe('voice/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm');
});
it('returns null for a URL that is not an image or audio proxy path', () => {
expect(mediaUrlToR2Key(VIDEO_URL)).toBeNull();
expect(mediaUrlToR2Key('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull();
});
// Documented, not endorsed. Unlike extractImageKeyFromProxyUrl this one
// matches on a substring with no shape check, so the key it produces is
// attacker-shaped whenever the URL is. The module has no callers today; if
// one appears it must use the extract* helpers instead. See the report.
it('accepts a substring match that the anchored extractor rejects', () => {
const hostile = 'https://evil.test/api/upload/image/../../videos/live.mp4';
expect(extractImageKeyFromProxyUrl(hostile)).toBeNull();
expect(mediaUrlToR2Key(hostile)).toBe('images/../../videos/live.mp4');
});
});
describe('SAFE_BUNNY_VIDEO_ID', () => {
it.each([
['abcd1234', true],
['a-b_c-d1', true],
['abcd123', false],
['abcd 1234', false],
['abcd/1234', false],
['../secret', false],
['a'.repeat(129), false],
['a'.repeat(128), true],
])('matches %s: %s', (value, expected) => {
expect(SAFE_BUNNY_VIDEO_ID.test(value)).toBe(expected);
});
});
describe('canDeleteAssetForViewer', () => {
const managed = {
canManageAssets: true,
viewerUserId: null,
viewerGuestIdentityId: null,
};
const signedIn = {
canManageAssets: false,
viewerUserId: 'user-1',
viewerGuestIdentityId: null,
};
const guest = {
canManageAssets: false,
viewerUserId: null,
viewerGuestIdentityId: 'guest-1',
};
it('lets a manager delete an asset they did not upload', () => {
expect(
canDeleteAssetForViewer(
{ uploadedByUserId: 'someone-else', uploadedByGuestIdentityId: null },
managed
)
).toBe(true);
});
it('lets a signed-in uploader delete their own asset', () => {
expect(
canDeleteAssetForViewer(
{ uploadedByUserId: 'user-1', uploadedByGuestIdentityId: null },
signedIn
)
).toBe(true);
});
it('refuses a signed-in non-manager somebody else asset', () => {
expect(
canDeleteAssetForViewer(
{ uploadedByUserId: 'user-2', uploadedByGuestIdentityId: null },
signedIn
)
).toBe(false);
});
it('lets a guest delete the asset their own guest identity uploaded', () => {
expect(
canDeleteAssetForViewer(
{ uploadedByUserId: null, uploadedByGuestIdentityId: 'guest-1' },
guest
)
).toBe(true);
});
it('refuses a guest another guest asset', () => {
expect(
canDeleteAssetForViewer(
{ uploadedByUserId: null, uploadedByGuestIdentityId: 'guest-2' },
guest
)
).toBe(false);
});
// The `!viewer.viewerUserId` guard: a signed-in caller is judged on their user
// id alone, so a stale guest cookie carried alongside a session cannot widen
// what they may delete.
it('ignores a matching guest identity when the viewer is signed in', () => {
expect(
canDeleteAssetForViewer(
{ uploadedByUserId: null, uploadedByGuestIdentityId: 'guest-1' },
{ canManageAssets: false, viewerUserId: 'user-1', viewerGuestIdentityId: 'guest-1' }
)
).toBe(false);
});
// Two nulls are not a match. Without the truthiness checks an anonymous
// viewer would be able to delete every anonymously uploaded asset.
it('refuses when both sides have no identity at all', () => {
expect(
canDeleteAssetForViewer(
{ uploadedByUserId: null, uploadedByGuestIdentityId: null },
{ canManageAssets: false, viewerUserId: null, viewerGuestIdentityId: null }
)
).toBe(false);
});
it('refuses when the asset has no uploader and the viewer is an identified guest', () => {
expect(
canDeleteAssetForViewer({ uploadedByUserId: null, uploadedByGuestIdentityId: null }, guest)
).toBe(false);
});
});
describe('getVideoAssetAccessContext', () => {
function shareCookies(videoId: string, token: string, passwordVerified = false) {
return {
[getShareSessionCookieName(videoId)]: createShareSessionValue(
token,
videoId,
passwordVerified
),
};
}
it('returns null for a video that does not exist', async () => {
signedOut();
expect(
await getVideoAssetAccessContext(apiRequest('/api/videos/nope/assets'), 'nope')
).toBeNull();
});
it('denies everything to an anonymous caller on a private project', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const video = await createVideo({ projectId: scenario.project.id });
signedOut();
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`),
video.id
);
expect(context).not.toBeNull();
expect(context?.hasViewAccess).toBe(false);
expect(context?.canUploadAssets).toBe(false);
expect(context?.canDownloadAssets).toBe(false);
expect(context?.canManageAssets).toBe(false);
expect(context?.viewerUserId).toBeNull();
});
it('grants everything to the project owner and echoes the project shape back', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: false });
const video = await createVideo({ projectId: scenario.project.id, title: 'Cut 3' });
signedInAs(scenario.owner);
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`),
video.id
);
expect(context?.hasViewAccess).toBe(true);
expect(context?.canUploadAssets).toBe(true);
// An editor may always download, whatever allowDownloads says.
expect(context?.canDownloadAssets).toBe(true);
expect(context?.canManageAssets).toBe(true);
expect(context?.viewerUserId).toBe(scenario.owner.id);
expect(context?.viewerGuestIdentityId).toBeNull();
expect(context?.video.id).toBe(video.id);
expect(context?.video.title).toBe('Cut 3');
expect(context?.video.projectId).toBe(scenario.project.id);
expect(context?.video.project.workspace.id).toBe(scenario.workspace.id);
expect(context?.video.project.workspace.ownerId).toBe(scenario.owner.id);
});
it('denies everything to a signed-in stranger', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const video = await createVideo({ projectId: scenario.project.id });
signedInAs(await createUser());
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`),
video.id
);
expect(context?.hasViewAccess).toBe(false);
expect(context?.canUploadAssets).toBe(false);
expect(context?.canDownloadAssets).toBe(false);
});
// canManageAssets is `access.canEdit`, which a COMMENTATOR does not have; the
// other three flags are separate computations and must not collapse onto it.
it('gives a project COMMENTATOR view and upload but not manage', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: false });
const video = await createVideo({ projectId: scenario.project.id });
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`),
video.id
);
expect(context?.hasViewAccess).toBe(true);
expect(context?.canUploadAssets).toBe(true);
expect(context?.canManageAssets).toBe(false);
// allowDownloads is off and the viewer cannot edit, so no export.
expect(context?.canDownloadAssets).toBe(false);
});
it('lets a non-editing member download once the project allows downloads', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: true });
const video = await createVideo({ projectId: scenario.project.id });
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`),
video.id
);
expect(context?.canDownloadAssets).toBe(true);
});
it('denies a workspace member once the workspace owner loses billing access', async () => {
const expiredOwner = await createExpiredUser();
const scenario = await seedProject({ visibility: 'PRIVATE', ownerUser: expiredOwner });
const video = await createVideo({ projectId: scenario.project.id });
const member = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: member.id,
role: 'COMMENTATOR',
});
signedInAs(member);
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`),
video.id
);
expect(context?.hasViewAccess).toBe(false);
expect(context?.canUploadAssets).toBe(false);
});
it('grants view but not upload to an anonymous holder of a VIEW share link', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const video = await createVideo({ projectId: scenario.project.id });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: video.id,
permission: 'VIEW',
allowGuests: true,
});
signedOut();
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`, {
cookies: shareCookies(video.id, link.token),
}),
video.id
);
expect(context?.hasViewAccess).toBe(true);
expect(context?.canUploadAssets).toBe(false);
expect(context?.canDownloadAssets).toBe(false);
expect(context?.canManageAssets).toBe(false);
});
it('grants upload to an anonymous holder of a COMMENT share link that allows guests', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const video = await createVideo({ projectId: scenario.project.id });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: video.id,
permission: 'COMMENT',
allowGuests: true,
});
signedOut();
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`, {
cookies: shareCookies(video.id, link.token),
}),
video.id,
'COMMENT'
);
expect(context?.hasViewAccess).toBe(true);
expect(context?.canUploadAssets).toBe(true);
expect(context?.canManageAssets).toBe(false);
});
// allowGuests off means the link only works for someone with an account, and
// the upload flag is where that shows up for an anonymous caller.
it('refuses upload to an anonymous COMMENT share when guests are not allowed', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const video = await createVideo({ projectId: scenario.project.id });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: video.id,
permission: 'COMMENT',
allowGuests: false,
});
signedOut();
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`, {
cookies: shareCookies(video.id, link.token),
}),
video.id,
'COMMENT'
);
expect(context?.canUploadAssets).toBe(false);
});
it('grants download through a share link that allows downloads', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: false });
const video = await createVideo({ projectId: scenario.project.id });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: video.id,
permission: 'VIEW',
allowGuests: true,
allowDownloads: true,
});
signedOut();
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`, {
cookies: shareCookies(video.id, link.token),
}),
video.id
);
expect(context?.canDownloadAssets).toBe(true);
});
// The cookie is bound to a video id and HMAC-signed, so a session minted for
// one video must not carry over to another.
it('ignores a share cookie minted for a different video', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const video = await createVideo({ projectId: scenario.project.id });
const otherVideo = await createVideo({ projectId: scenario.project.id });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: otherVideo.id,
permission: 'COMMENT',
allowGuests: true,
});
signedOut();
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`, {
cookies: shareCookies(otherVideo.id, link.token),
}),
video.id,
'COMMENT'
);
expect(context?.hasViewAccess).toBe(false);
expect(context?.canUploadAssets).toBe(false);
});
it('reads the guest identity for an anonymous caller and drops it for a signed-in one', async () => {
const scenario = await seedProject({ visibility: 'PUBLIC' });
const video = await createVideo({ projectId: scenario.project.id });
// A signed cookie is the only shape getGuestIdentityFromRequest accepts, so
// it is minted the same way the comment routes mint it.
const { NextResponse } = await import('next/server');
const { setGuestIdentityCookie } = await import('@/lib/guest-identity');
const carrier = NextResponse.json({});
setGuestIdentityCookie(carrier, 'guest-identity-under-test');
const cookieValue = carrier.cookies.get('openframe_guest_identity')?.value ?? '';
const cookies = { openframe_guest_identity: cookieValue };
signedOut();
const anonymous = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`, { cookies }),
video.id
);
expect(anonymous?.viewerGuestIdentityId).toBe('guest-identity-under-test');
expect(anonymous?.viewerUserId).toBeNull();
signedInAs(scenario.owner);
const authenticated = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`, { cookies }),
video.id
);
expect(authenticated?.viewerUserId).toBe(scenario.owner.id);
expect(authenticated?.viewerGuestIdentityId).toBeNull();
});
it('gives an anonymous caller view access to a PUBLIC project but no upload or manage', async () => {
const scenario = await seedProject({ visibility: 'PUBLIC' });
const video = await createVideo({ projectId: scenario.project.id });
signedOut();
const context = await getVideoAssetAccessContext(
apiRequest(`/api/videos/${video.id}/assets`),
video.id
);
expect(context?.hasViewAccess).toBe(true);
expect(context?.canUploadAssets).toBe(false);
expect(context?.canManageAssets).toBe(false);
expect(context?.canDownloadAssets).toBe(false);
});
});
+483
View File
@@ -0,0 +1,483 @@
// Exercises lib/video-delete.ts, the cascade behind the bulk-delete route.
//
// The module does three things in a fixed order and the order is the whole
// story: it reads the media URLs while the rows still exist, it deletes the
// rows, and only then does it talk to storage. Reading the URLs first is
// mandatory (the cascade takes the comment rows with the video), and deleting
// the rows first means a storage failure cannot be retried, which is a
// behaviour the tests below pin down rather than paper over.
//
// tests/setup/api.ts does not stub `r2Client`, which deleteMediaFilesBestEffort
// reaches through, so this file replaces it with a recorder. Bunny goes over
// fetch(), which is stubbed per test.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { deleteProjectVideosWithCleanup } from '@/lib/video-delete';
import {
createComment,
createProject,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
seedProject,
} from '../factories';
const r2 = vi.hoisted(() => ({
bucket: 'openframe-delete-test-bucket',
deletedKeys: [] as string[],
rejectKeys: new Set<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?: { Key?: string } }) => {
const key = command.input?.Key ?? '';
if (r2.rejectKeys.has(key)) throw new Error(`storage refused ${key}`);
r2.deletedKeys.push(key);
return {};
},
},
};
});
const TARGET_VIDEO_URL = '/api/upload/video/cccccccc-cccc-4ccc-8ccc-ccccccccccc1.mp4';
const TARGET_VIDEO_KEY = 'videos/cccccccc-cccc-4ccc-8ccc-ccccccccccc1.mp4';
const TARGET_COMMENT_IMAGE = '/api/upload/image/cccccccc-cccc-4ccc-8ccc-ccccccccccc2.png';
const TARGET_COMMENT_IMAGE_KEY = 'images/cccccccc-cccc-4ccc-8ccc-ccccccccccc2.png';
const TARGET_ASSET_IMAGE = '/api/upload/image/cccccccc-cccc-4ccc-8ccc-ccccccccccc3.png';
const TARGET_ASSET_IMAGE_KEY = 'images/cccccccc-cccc-4ccc-8ccc-ccccccccccc3.png';
const SURVIVOR_VIDEO_URL = '/api/upload/video/dddddddd-dddd-4ddd-8ddd-ddddddddddd1.mp4';
const SURVIVOR_VIDEO_KEY = 'videos/dddddddd-dddd-4ddd-8ddd-ddddddddddd1.mp4';
const SURVIVOR_COMMENT_IMAGE = '/api/upload/image/dddddddd-dddd-4ddd-8ddd-ddddddddddd2.png';
const SURVIVOR_COMMENT_IMAGE_KEY = 'images/dddddddd-dddd-4ddd-8ddd-ddddddddddd2.png';
beforeEach(() => {
r2.deletedKeys.length = 0;
r2.rejectKeys.clear();
vi.mocked(revalidatePath).mockClear();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** A video with an r2 version, a commented image and an R2 image asset. */
async function seedDeletableVideo(input: {
projectId: string;
ownerId: string;
videoUrl: string;
commentImageUrl: string;
assetUrl?: string;
}) {
const video = await createVideo({ projectId: input.projectId });
const version = await createVersion({
videoParentId: video.id,
providerId: 'r2',
originalUrl: input.videoUrl,
});
const comment = await createComment({
versionId: version.id,
imageUrl: input.commentImageUrl,
});
const asset = input.assetUrl
? await createVideoAsset({
videoId: video.id,
billedUserId: input.ownerId,
provider: 'R2_IMAGE',
sourceUrl: input.assetUrl,
})
: null;
return { video, version, comment, asset };
}
describe('deleteProjectVideosWithCleanup input validation', () => {
it('throws EMPTY_VIDEO_IDS for an empty list', async () => {
const scenario = await seedProject();
await expect(deleteProjectVideosWithCleanup(scenario.project.id, [])).rejects.toThrow(
'EMPTY_VIDEO_IDS'
);
});
it('throws VIDEO_NOT_FOUND for an id that does not exist', async () => {
const scenario = await seedProject();
await expect(
deleteProjectVideosWithCleanup(scenario.project.id, ['no-such-video'])
).rejects.toThrow('VIDEO_NOT_FOUND');
});
// The projectId in the lookup is the only thing stopping a caller who is an
// admin of project A from naming a video in project B. If the lookup ever
// stopped scoping on it, this is where it shows.
it('refuses the whole batch and deletes nothing when one id belongs to another project', async () => {
const scenario = await seedProject();
const otherProject = await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
const mine = await createVideo({ projectId: scenario.project.id });
const theirs = await createVideo({ projectId: otherProject.id });
await expect(
deleteProjectVideosWithCleanup(scenario.project.id, [mine.id, theirs.id])
).rejects.toThrow('VIDEO_NOT_FOUND');
expect(await db.video.count()).toBe(2);
expect(r2.deletedKeys).toEqual([]);
});
});
describe('deleteProjectVideosWithCleanup cascade', () => {
it('removes the video with its versions, comments and assets', async () => {
const scenario = await seedProject();
const seeded = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
assetUrl: TARGET_ASSET_IMAGE,
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [seeded.video.id]);
expect(result.deletedCount).toBe(1);
expect(await db.video.count()).toBe(0);
expect(await db.videoVersion.count()).toBe(0);
expect(await db.comment.count()).toBe(0);
expect(await db.videoAsset.count()).toBe(0);
});
// The counterpart of the cascade: everything not named survives, rows and
// objects alike.
it('leaves a sibling video in the same project untouched, rows and objects', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
assetUrl: TARGET_ASSET_IMAGE,
});
const survivor = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: SURVIVOR_VIDEO_URL,
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
});
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
expect((await db.video.findMany({ select: { id: true } })).map((row) => row.id)).toEqual([
survivor.video.id,
]);
expect(await db.videoVersion.count()).toBe(1);
expect(await db.comment.count()).toBe(1);
expect(r2.deletedKeys).not.toContain(SURVIVOR_VIDEO_KEY);
expect(r2.deletedKeys).not.toContain(SURVIVOR_COMMENT_IMAGE_KEY);
});
it('leaves a video in another project of the same workspace untouched', async () => {
const scenario = await seedProject();
const otherProject = await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
await seedDeletableVideo({
projectId: otherProject.id,
ownerId: scenario.owner.id,
videoUrl: SURVIVOR_VIDEO_URL,
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
});
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
expect(await db.video.count()).toBe(1);
expect(r2.deletedKeys).not.toContain(SURVIVOR_VIDEO_KEY);
});
it('deletes exactly the storage objects the removed video referenced', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
assetUrl: TARGET_ASSET_IMAGE,
});
await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: SURVIVOR_VIDEO_URL,
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
});
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
expect(new Set(r2.deletedKeys)).toEqual(
new Set([TARGET_VIDEO_KEY, TARGET_COMMENT_IMAGE_KEY, TARGET_ASSET_IMAGE_KEY])
);
});
it('counts a repeated id once', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [
target.video.id,
target.video.id,
]);
// Without the dedupe, videos.length (1) would not match uniqueVideoIds
// (2) and the call would throw VIDEO_NOT_FOUND on a perfectly valid request.
expect(result.deletedCount).toBe(1);
expect(await db.video.count()).toBe(0);
});
it('deletes several videos in one call and reports the count', async () => {
const scenario = await seedProject();
const first = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
const second = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: SURVIVOR_VIDEO_URL,
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [
first.video.id,
second.video.id,
]);
expect(result.deletedCount).toBe(2);
expect(await db.video.count()).toBe(0);
expect(new Set(r2.deletedKeys)).toEqual(
new Set([
TARGET_VIDEO_KEY,
TARGET_COMMENT_IMAGE_KEY,
SURVIVOR_VIDEO_KEY,
SURVIVOR_COMMENT_IMAGE_KEY,
])
);
});
it('revalidates the project page so the video list is not served from cache', async () => {
const scenario = await seedProject();
const target = await createVideo({ projectId: scenario.project.id });
await deleteProjectVideosWithCleanup(scenario.project.id, [target.id]);
expect(vi.mocked(revalidatePath)).toHaveBeenCalledWith(`/projects/${scenario.project.id}`);
});
});
describe('deleteProjectVideosWithCleanup and Bunny', () => {
/** Stubs the Bunny API and records the URL of every request made to it. */
function stubBunny(status: number) {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '9999');
const requestedUrls: string[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
requestedUrls.push(String(input));
return new Response(null, { status });
});
vi.stubGlobal('fetch', fetchMock);
return { fetchMock, requestedUrls };
}
it('asks Bunny to delete bunny versions and bunny assets, and nothing else', async () => {
const { requestedUrls } = stubBunny(200);
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
versionNumber: 1,
providerId: 'bunny',
providerVideoId: 'bunny-version-id-1',
});
await createVersion({
videoParentId: video.id,
versionNumber: 2,
providerId: 'youtube',
providerVideoId: 'youtube-video-id-1',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'BUNNY',
providerVideoId: 'bunny-asset-id-1',
sourceUrl: 'https://iframe.mediadelivery.net/play/9999/bunny-asset-id-1',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'YOUTUBE',
providerVideoId: 'youtube-asset-id-1',
sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [video.id]);
expect(new Set(requestedUrls)).toEqual(
new Set([
'https://video.bunnycdn.com/library/9999/videos/bunny-version-id-1',
'https://video.bunnycdn.com/library/9999/videos/bunny-asset-id-1',
])
);
expect(result.cleanupInput.bunny).toEqual({ attempted: 2, failed: 0, failedIds: [] });
expect(result.cleanupWarnings).toBeUndefined();
});
it('makes no Bunny request when the video has no bunny media', async () => {
const { fetchMock } = stubBunny(200);
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
expect(fetchMock).not.toHaveBeenCalled();
expect(result.cleanupInput.bunny.attempted).toBe(0);
});
});
describe('deleteProjectVideosWithCleanup when storage fails', () => {
// The rows go first and the objects go second, with no transaction spanning
// the two. A refused DELETE therefore leaves the object behind with nothing
// in the database still pointing at it: the caller cannot retry, because the
// video id it would retry with no longer resolves. The result object is the
// only trace, which is why the warnings are asserted here.
it('still removes the rows and surfaces the orphaned key as a warning', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
r2.rejectKeys.add(TARGET_VIDEO_KEY);
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
expect(result.deletedCount).toBe(1);
// The row is gone even though its object is not.
expect(await db.video.count()).toBe(0);
expect(result.cleanupInput.r2).toEqual({
attempted: 2,
failed: 1,
failedKeys: [TARGET_VIDEO_KEY],
});
expect(result.cleanupWarnings).toEqual({ r2: { attempted: 2, failed: 1 } });
// The rest of the sweep still ran.
expect(r2.deletedKeys).toEqual([TARGET_COMMENT_IMAGE_KEY]);
});
it('reports a Bunny failure without failing the delete', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '9999');
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 500 }))
);
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-version-id-2',
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [video.id]);
expect(await db.video.count()).toBe(0);
expect(result.cleanupWarnings).toEqual({ bunny: { attempted: 1, failed: 1 } });
});
it('reports no warnings when both providers succeed', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
expect(result.cleanupWarnings).toBeUndefined();
expect(result.cleanupInput.r2.failed).toBe(0);
});
});
describe('deleteProjectVideosWithCleanup media collection order', () => {
// collectVideoMediaUrls runs before the deleteMany. If it ran after, the
// cascade would already have taken the comment rows and their images would
// stay in storage forever, silently.
it('deletes comment media even though the cascade removes the comment rows', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
expect(await db.comment.count()).toBe(0);
expect(r2.deletedKeys).toContain(TARGET_COMMENT_IMAGE_KEY);
});
it('scopes media collection per video so two videos sharing a project do not cross over', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
const target = await seedDeletableVideo({
projectId: project.id,
ownerId: owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
await seedDeletableVideo({
projectId: project.id,
ownerId: owner.id,
videoUrl: SURVIVOR_VIDEO_URL,
commentImageUrl: SURVIVOR_COMMENT_IMAGE,
});
const result = await deleteProjectVideosWithCleanup(project.id, [target.video.id]);
expect(result.cleanupInput.r2.attempted).toBe(2);
expect(new Set(r2.deletedKeys)).toEqual(new Set([TARGET_VIDEO_KEY, TARGET_COMMENT_IMAGE_KEY]));
});
});
+885
View File
@@ -0,0 +1,885 @@
// 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();
});
});
+345
View File
@@ -0,0 +1,345 @@
// Result scoping for GET /api/search.
//
// The auth matrix already proves an anonymous caller gets 401, and beyond that
// there is nothing to test on the authorization axis: the route reads the
// caller's id straight off the session, so no "forbidden caller" exists. The
// question this file asks instead is a data-leak one, and nothing asserted it
// before: does the search actually restrict its rows to the caller's own
// tenants?
//
// It matters because search is the one endpoint that queries `project`,
// `workspace` and `video` globally rather than through a project id in the URL.
// Every filter is inline in the handler, none of it goes through
// `checkProjectAccess()`, and there is no shared helper that a regression would
// have to break twice. Dropping the `projectAccessFilter` clause from the video
// query would turn the search box into a list of every video title in the
// database, and until this file nothing would have noticed.
//
// Every test uses a term that appears in exactly one tenant's rows, so a hit is
// unambiguous. Each refusal is paired with the same query run by a caller who
// should see it, which is what rules out an empty result that came from the
// query never matching anything in the first place.
import { describe, expect, it } from 'vitest';
import { GET as search } from '@/app/api/search/route';
import { GET as listProjects } from '@/app/api/projects/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createUser,
createVideo,
createWorkspace,
nextSeq,
seedProject,
} from '../factories';
interface SearchResults {
projects: Array<{ id: string; name: string }>;
workspaces: Array<{ id: string; name: string }>;
videos: Array<{ id: string; title: string }>;
}
async function searchFor(term: string): Promise<SearchResults> {
const response = await callRoute(
search,
apiRequest('/api/search', { searchParams: { q: term } })
);
expect(response.status).toBe(200);
return readData<SearchResults>(response);
}
/** A term that cannot collide with a factory default name or another test's rows. */
function uniqueTerm(): string {
return `Zephyrine${nextSeq()}`;
}
// ---------------------------------------------------------------------------
// Cross-tenant leakage
// ---------------------------------------------------------------------------
describe('GET /api/search does not reach into another tenant', () => {
it('returns nothing to an anonymous caller', async () => {
const term = uniqueTerm();
const { project } = await seedProject({ projectName: `${term} project` });
await createVideo({ projectId: project.id, title: `${term} cut` });
signedOut();
const response = await callRoute(
search,
apiRequest('/api/search', { searchParams: { q: term } })
);
expect(response.status).toBe(401);
});
it('hides a stranger project matching the term by name', async () => {
const term = uniqueTerm();
await seedProject({ projectName: `${term} deliverables` });
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.projects).toEqual([]);
});
// The positive control for the case above. Same term, same row, and the only
// difference is who is asking, so an empty result cannot be blamed on the
// query failing to match.
it('shows that same project to its owner', async () => {
const term = uniqueTerm();
const { owner } = await seedProject({ projectName: `${term} deliverables` });
signedInAs(owner);
const results = await searchFor(term);
expect(results.projects.map((project) => project.name)).toEqual([`${term} deliverables`]);
});
it('hides a stranger project matching the term only in its description', async () => {
const term = uniqueTerm();
const scenario = await seedProject();
await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
name: 'Unremarkable name',
description: `Rough cut for the ${term} campaign`,
});
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.projects).toEqual([]);
});
it('shows the description match to the project owner', async () => {
const term = uniqueTerm();
const scenario = await seedProject();
await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
name: 'Unremarkable name',
description: `Rough cut for the ${term} campaign`,
});
signedInAs(scenario.owner);
const results = await searchFor(term);
expect(results.projects).toHaveLength(1);
});
// The headline case from the gap inventory: a video title is the most
// sensitive string in this product's search index, because it is usually a
// client name or an unannounced campaign.
it('hides a video in a stranger project whose title matches the term', async () => {
const term = uniqueTerm();
const { project } = await seedProject();
await createVideo({ projectId: project.id, title: `${term} launch cut` });
// The outsider owns a real tenant of their own, so nothing about the request
// is unusual: they simply have no relationship to the project holding the hit.
await seedProject();
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.videos).toEqual([]);
});
it('shows that same video to the owner of the project holding it', async () => {
const term = uniqueTerm();
const { owner, project } = await seedProject();
await createVideo({ projectId: project.id, title: `${term} launch cut` });
signedInAs(owner);
const results = await searchFor(term);
expect(results.videos.map((video) => video.title)).toEqual([`${term} launch cut`]);
});
it('hides a stranger workspace matching the term by name', async () => {
const term = uniqueTerm();
const stranger = await createUser();
await createWorkspace({ ownerId: stranger.id, name: `${term} Studio` });
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.workspaces).toEqual([]);
});
it('shows that same workspace to its owner', async () => {
const term = uniqueTerm();
const stranger = await createUser();
await createWorkspace({ ownerId: stranger.id, name: `${term} Studio` });
signedInAs(stranger);
const results = await searchFor(term);
expect(results.workspaces.map((workspace) => workspace.name)).toEqual([`${term} Studio`]);
});
// Search is scoped by membership, not by visibility: `checkProjectAccess()`
// would let this caller open the project, but it does not surface in their
// search. Pinned because it is the one place the two rules deliberately differ,
// and because widening search to match the access check would be a real
// exposure of every public project's video titles.
it('hides a PUBLIC stranger project the caller has never joined', async () => {
const term = uniqueTerm();
const { project } = await seedProject({
visibility: 'PUBLIC',
projectName: `${term} open project`,
});
await createVideo({ projectId: project.id, title: `${term} open cut` });
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.projects).toEqual([]);
expect(results.videos).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// The three ways in
// ---------------------------------------------------------------------------
// The access filter has three branches. Each one is exercised here, because a
// scoping test that only proves "strangers see nothing" would still pass if the
// filter had collapsed to `ownerId` and quietly stopped showing collaborators
// their own work.
describe('GET /api/search reaches everything the caller is entitled to', () => {
it('shows a project the caller was added to directly', async () => {
const term = uniqueTerm();
const { project } = await seedProject({ projectName: `${term} shared cut` });
const collaborator = await createUser();
await addProjectMember({
projectId: project.id,
userId: collaborator.id,
role: 'COMMENTATOR',
});
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.id)).toEqual([project.id]);
});
it('shows videos in a project the caller was added to directly', async () => {
const term = uniqueTerm();
const { project } = await seedProject();
const video = await createVideo({ projectId: project.id, title: `${term} rough cut` });
const collaborator = await createUser();
await addProjectMember({ projectId: project.id, userId: collaborator.id });
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.videos.map((entry) => entry.id)).toEqual([video.id]);
});
// A workspace member with no project membership row at all. This is the branch
// most likely to be dropped by accident, because the other two are obvious.
it('shows a project the caller reaches only through workspace membership', async () => {
const term = uniqueTerm();
const { workspace, project } = await seedProject({ projectName: `${term} workspace cut` });
const video = await createVideo({ projectId: project.id, title: `${term} workspace video` });
const workspaceMember = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: workspaceMember.id });
signedInAs(workspaceMember);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.id)).toEqual([project.id]);
expect(results.videos.map((entry) => entry.id)).toEqual([video.id]);
});
it('shows a workspace the caller is a member of', async () => {
const term = uniqueTerm();
const stranger = await createUser();
const workspace = await createWorkspace({ ownerId: stranger.id, name: `${term} Studio` });
const member = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: member.id });
signedInAs(member);
const results = await searchFor(term);
expect(results.workspaces.map((entry) => entry.id)).toEqual([workspace.id]);
});
// A project membership must not leak the enclosing workspace, which usually
// carries the agency's own name and its other clients.
it('does not show the enclosing workspace to a project-only member', async () => {
const term = uniqueTerm();
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id, name: `${term} Studio` });
const project = await createProject({
ownerId: owner.id,
workspaceId: workspace.id,
name: `${term} client project`,
});
const collaborator = await createUser();
await addProjectMember({ projectId: project.id, userId: collaborator.id });
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.id)).toEqual([project.id]);
expect(results.workspaces).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Billing
// ---------------------------------------------------------------------------
// Pins current behaviour, and the behaviour is inconsistent. See the report
// accompanying this suite: GET /api/projects filters every row through
// `workspace.owner: buildBillingAccessWhereInput()`, and `checkProjectAccess()`
// makes `hasAccess` false the moment the workspace owner's billing lapses, so
// the project itself answers 403. /api/search applies no billing filter at all
// and keeps returning the names. Changing that means changing this test.
describe('GET /api/search and lapsed billing', () => {
it('keeps returning a project whose workspace owner has lost billing access', async () => {
const term = uniqueTerm();
const expiredOwner = await createExpiredUser();
await seedProject({ ownerUser: expiredOwner, projectName: `${term} lapsed project` });
signedInAs(expiredOwner);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.name)).toEqual([`${term} lapsed project`]);
});
// The same caller, the same row, through the list endpoint instead. This is
// the contrast that makes the case above a finding rather than a preference.
it('is hidden from GET /api/projects for the same caller and the same row', async () => {
const expiredOwner = await createExpiredUser();
await seedProject({ ownerUser: expiredOwner, projectName: 'Lapsed project' });
signedInAs(expiredOwner);
const response = await callRoute(listProjects, apiRequest('/api/projects'));
expect(response.status).toBe(200);
const { projects } = await readData<{ projects: Array<{ id: string }> }>(response);
expect(projects).toEqual([]);
});
it('keeps returning a video title from a lapsed workspace to a collaborator', async () => {
const term = uniqueTerm();
const expiredOwner = await createExpiredUser();
const { project } = await seedProject({ ownerUser: expiredOwner });
await createVideo({ projectId: project.id, title: `${term} lapsed cut` });
const collaborator = await createUser();
await addProjectMember({ projectId: project.id, userId: collaborator.id });
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.videos.map((entry) => entry.title)).toEqual([`${term} lapsed cut`]);
});
});