mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
+205
-22
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 owner’s 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 collaborator’s 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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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('<script>');
|
||||
});
|
||||
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||
import type { ApprovalDecision, ApprovalRequest } from '@/components/video-page/types';
|
||||
|
||||
type Params = Parameters<typeof useApprovals>[0];
|
||||
|
||||
const VERSION_ID = 'ver1';
|
||||
const PROJECT_ID = 'proj1';
|
||||
|
||||
function makeDecision(overrides: Partial<ApprovalDecision> = {}): ApprovalDecision {
|
||||
return {
|
||||
id: 'dec1',
|
||||
approverId: 'user2',
|
||||
status: 'PENDING',
|
||||
note: null,
|
||||
respondedAt: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
approver: { id: 'user2', name: 'Linus', email: '[email protected]', image: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
|
||||
return {
|
||||
id: 'req1',
|
||||
status: 'PENDING',
|
||||
requestedById: 'user1',
|
||||
message: null,
|
||||
resolvedAt: null,
|
||||
canceledAt: null,
|
||||
canceledById: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
requestedBy: { id: 'user1', name: 'Ada', email: '[email protected]', image: null },
|
||||
canceledBy: null,
|
||||
decisions: [makeDecision()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ok(payload: unknown) {
|
||||
return { ok: true, status: 200, json: () => Promise.resolve(payload) };
|
||||
}
|
||||
|
||||
function fail(status: number, payload: unknown = {}) {
|
||||
return { ok: false, status, json: () => Promise.resolve(payload) };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
/** What the approvals GET answers with. Reassign to change it mid-test. */
|
||||
let listedRequests: ApprovalRequest[];
|
||||
let listedCandidates: unknown[];
|
||||
|
||||
function callsTo(url: string, method?: string) {
|
||||
return fetchMock.mock.calls.filter(
|
||||
(call) => call[0] === url && (call[1]?.method ?? undefined) === method
|
||||
);
|
||||
}
|
||||
|
||||
function bodyOf(call: unknown[]): unknown {
|
||||
const init = call[1] as { body?: string };
|
||||
return init.body === undefined ? undefined : JSON.parse(init.body);
|
||||
}
|
||||
|
||||
type Harness = RenderHookResult<ReturnType<typeof useApprovals>, Params>;
|
||||
|
||||
function renderApprovals(overrides: Partial<Params> = {}): Harness {
|
||||
const initialProps: Params = {
|
||||
projectId: PROJECT_ID,
|
||||
activeVersionId: VERSION_ID,
|
||||
currentUserId: 'user1',
|
||||
...overrides,
|
||||
};
|
||||
return renderHook((props: Params) => useApprovals(props), { initialProps });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listedRequests = [makeRequest()];
|
||||
listedCandidates = [{ id: 'user2', name: 'Linus', email: '[email protected]', image: null }];
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
if (url === `/api/versions/${VERSION_ID}/approvals`) {
|
||||
return Promise.resolve(ok({ data: { requests: listedRequests } }));
|
||||
}
|
||||
if (url === `/api/projects/${PROJECT_ID}/approval-candidates`) {
|
||||
return Promise.resolve(ok({ data: { candidates: listedCandidates } }));
|
||||
}
|
||||
return Promise.resolve(ok({ data: {} }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useApprovals reading the request list', () => {
|
||||
it('reads the approvals of the active version, bypassing the cache', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/versions/${VERSION_ID}/approvals`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
expect(harness.result.current.requests).toEqual(listedRequests);
|
||||
expect(harness.result.current.error).toBe('');
|
||||
});
|
||||
|
||||
it('does not read anything before a version is selected', async () => {
|
||||
const harness = renderApprovals({ activeVersionId: null });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(harness.result.current.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags loading while the read is in flight and clears it afterwards', async () => {
|
||||
const pending = deferred<unknown>();
|
||||
fetchMock.mockReturnValue(pending.promise);
|
||||
const harness = renderApprovals();
|
||||
|
||||
let read: Promise<void> | undefined;
|
||||
act(() => {
|
||||
read = harness.result.current.fetchRequests();
|
||||
});
|
||||
expect(harness.result.current.isLoadingRequests).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve(ok({ data: { requests: [] } }));
|
||||
await read;
|
||||
});
|
||||
expect(harness.result.current.isLoadingRequests).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the message the server sent when the caller is forbidden', async () => {
|
||||
fetchMock.mockResolvedValue(fail(403, { error: 'Access denied' }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.error).toBe('Access denied');
|
||||
expect(harness.result.current.requests).toEqual([]);
|
||||
expect(harness.result.current.isLoadingRequests).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a generic message when a 500 carries no error string', async () => {
|
||||
fetchMock.mockResolvedValue(fail(500, {}));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.error).toBe('Failed to fetch approval requests');
|
||||
});
|
||||
|
||||
it('reports a network failure instead of leaving the panel spinning', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.error).toBe('Failed to fetch approval requests');
|
||||
expect(harness.result.current.isLoadingRequests).toBe(false);
|
||||
});
|
||||
|
||||
it('clears a previous error when the next read succeeds', async () => {
|
||||
fetchMock.mockResolvedValueOnce(fail(500, { error: 'Boom' }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
expect(harness.result.current.error).toBe('Boom');
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
expect(harness.result.current.error).toBe('');
|
||||
});
|
||||
|
||||
// A failed read must not silently empty a list the user is looking at.
|
||||
it('keeps the requests already on screen when a later read fails', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
expect(harness.result.current.requests).toHaveLength(1);
|
||||
|
||||
fetchMock.mockResolvedValue(fail(500, {}));
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('treats a body with no requests key as an empty list', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ data: {} }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.requests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useApprovals reading the candidate list', () => {
|
||||
it('reads the approvers of the project, bypassing the cache', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchCandidates();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/projects/${PROJECT_ID}/approval-candidates`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
expect(harness.result.current.candidates).toEqual(listedCandidates);
|
||||
});
|
||||
|
||||
it('does not read approvers without a project', async () => {
|
||||
const harness = renderApprovals({ projectId: undefined });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchCandidates();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the message the server sent when the caller cannot manage the project', async () => {
|
||||
fetchMock.mockResolvedValue(fail(403, { error: 'Access denied' }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchCandidates();
|
||||
});
|
||||
|
||||
expect(harness.result.current.error).toBe('Access denied');
|
||||
expect(harness.result.current.isLoadingCandidates).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a network failure while reading approvers', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchCandidates();
|
||||
});
|
||||
|
||||
expect(harness.result.current.error).toBe('Failed to fetch approvers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useApprovals creating a request', () => {
|
||||
it('posts the approvers to the active version and re-reads the list', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
let created: boolean | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createRequest(['user2', 'user3'], 'Please review');
|
||||
});
|
||||
|
||||
const post = callsTo(`/api/versions/${VERSION_ID}/approvals`, 'POST')[0];
|
||||
expect(bodyOf(post)).toEqual({ approverIds: ['user2', 'user3'], message: 'Please review' });
|
||||
expect(created).toBe(true);
|
||||
// The POST answers with the created row, but the hook trusts only the
|
||||
// re-read, so the list has to come back from the GET that follows.
|
||||
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(1);
|
||||
expect(harness.result.current.requests).toEqual(listedRequests);
|
||||
});
|
||||
|
||||
it('omits the message entirely when none was typed', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.createRequest(['user2'], '');
|
||||
});
|
||||
|
||||
const post = callsTo(`/api/versions/${VERSION_ID}/approvals`, 'POST')[0];
|
||||
expect(bodyOf(post)).toEqual({ approverIds: ['user2'] });
|
||||
});
|
||||
|
||||
it('refuses to post before a version is selected', async () => {
|
||||
const harness = renderApprovals({ activeVersionId: null });
|
||||
|
||||
let created: boolean | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createRequest(['user2']);
|
||||
});
|
||||
|
||||
expect(created).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the server error and skips the re-read when the post is rejected', async () => {
|
||||
fetchMock.mockResolvedValue(fail(403, { error: 'Only editors can request approval' }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
let created: boolean | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createRequest(['user2']);
|
||||
});
|
||||
|
||||
expect(created).toBe(false);
|
||||
expect(harness.result.current.error).toBe('Only editors can request approval');
|
||||
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(0);
|
||||
expect(harness.result.current.isSubmittingRequest).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a network failure without hanging the submit flag', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderApprovals();
|
||||
|
||||
let created: boolean | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createRequest(['user2']);
|
||||
});
|
||||
|
||||
expect(created).toBe(false);
|
||||
expect(harness.result.current.error).toBe('Failed to create approval request');
|
||||
expect(harness.result.current.isSubmittingRequest).toBe(false);
|
||||
});
|
||||
|
||||
// KNOWN FRAGILITY, pinned rather than fixed. `createRequest` has no in-flight
|
||||
// guard of its own, so a double-clicked "Request approval" button sends two
|
||||
// POSTs. The route de-duplicates server side, which is why this has not
|
||||
// surfaced; the hook must at least settle cleanly afterwards.
|
||||
it('sends one post per click and still settles when clicked twice', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.all([
|
||||
harness.result.current.createRequest(['user2']),
|
||||
harness.result.current.createRequest(['user2']),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, 'POST')).toHaveLength(2);
|
||||
expect(harness.result.current.isSubmittingRequest).toBe(false);
|
||||
expect(harness.result.current.requests).toEqual(listedRequests);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useApprovals deciding', () => {
|
||||
it('posts the decision to the request and re-reads the list', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
let decided: boolean | undefined;
|
||||
await act(async () => {
|
||||
decided = await harness.result.current.submitDecision('req1', 'APPROVED', 'Looks good');
|
||||
});
|
||||
|
||||
const post = callsTo('/api/approvals/req1/decision', 'POST')[0];
|
||||
expect(bodyOf(post)).toEqual({ decision: 'APPROVED', note: 'Looks good' });
|
||||
expect((post[1] as { headers: Record<string, string> }).headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
expect(decided).toBe(true);
|
||||
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects without a note when none was written', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.submitDecision('req1', 'REJECTED');
|
||||
});
|
||||
|
||||
expect(bodyOf(callsTo('/api/approvals/req1/decision', 'POST')[0])).toEqual({
|
||||
decision: 'REJECTED',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the server error when the caller is not an approver', async () => {
|
||||
fetchMock.mockResolvedValue(fail(403, { error: 'You are not an approver on this request' }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
let decided: boolean | undefined;
|
||||
await act(async () => {
|
||||
decided = await harness.result.current.submitDecision('req1', 'APPROVED');
|
||||
});
|
||||
|
||||
expect(decided).toBe(false);
|
||||
expect(harness.result.current.error).toBe('You are not an approver on this request');
|
||||
expect(harness.result.current.isSubmittingDecision).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a network failure while deciding', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.submitDecision('req1', 'APPROVED');
|
||||
});
|
||||
|
||||
expect(harness.result.current.error).toBe('Failed to submit approval decision');
|
||||
expect(harness.result.current.isSubmittingDecision).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useApprovals canceling', () => {
|
||||
it('posts to the cancel endpoint with no body and re-reads the list', async () => {
|
||||
const harness = renderApprovals();
|
||||
|
||||
let canceled: boolean | undefined;
|
||||
await act(async () => {
|
||||
canceled = await harness.result.current.cancelRequest('req1');
|
||||
});
|
||||
|
||||
const post = callsTo('/api/approvals/req1/cancel', 'POST')[0];
|
||||
expect(post[1]).toEqual({ method: 'POST' });
|
||||
expect(canceled).toBe(true);
|
||||
expect(callsTo(`/api/versions/${VERSION_ID}/approvals`, undefined)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('surfaces the server error when the request cannot be canceled', async () => {
|
||||
fetchMock.mockResolvedValue(fail(409, { error: 'Request is already resolved' }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
let canceled: boolean | undefined;
|
||||
await act(async () => {
|
||||
canceled = await harness.result.current.cancelRequest('req1');
|
||||
});
|
||||
|
||||
expect(canceled).toBe(false);
|
||||
expect(harness.result.current.error).toBe('Request is already resolved');
|
||||
expect(harness.result.current.isCancelingRequest).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a network failure while canceling', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.cancelRequest('req1');
|
||||
});
|
||||
|
||||
expect(harness.result.current.error).toBe('Failed to cancel approval request');
|
||||
expect(harness.result.current.isCancelingRequest).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useApprovals derived state', () => {
|
||||
it('finds the one pending request among resolved ones', async () => {
|
||||
listedRequests = [
|
||||
makeRequest({ id: 'req-new', status: 'PENDING' }),
|
||||
makeRequest({ id: 'req-old', status: 'APPROVED' }),
|
||||
makeRequest({ id: 'req-older', status: 'CANCELED' }),
|
||||
];
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.activePendingRequest?.id).toBe('req-new');
|
||||
});
|
||||
|
||||
it('reports no pending request once everything is resolved', async () => {
|
||||
listedRequests = [makeRequest({ id: 'req-old', status: 'REJECTED' })];
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.activePendingRequest).toBeNull();
|
||||
expect(harness.result.current.myPendingDecision).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the current user own undecided slot', async () => {
|
||||
listedRequests = [
|
||||
makeRequest({
|
||||
decisions: [
|
||||
makeDecision({ id: 'dec-other', approverId: 'user2', status: 'PENDING' }),
|
||||
makeDecision({ id: 'dec-mine', approverId: 'user1', status: 'PENDING' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const harness = renderApprovals({ currentUserId: 'user1' });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.myPendingDecision?.id).toBe('dec-mine');
|
||||
});
|
||||
|
||||
it('hides the decide prompt once the user has already answered', async () => {
|
||||
listedRequests = [
|
||||
makeRequest({
|
||||
decisions: [
|
||||
makeDecision({ id: 'dec-mine', approverId: 'user1', status: 'APPROVED' }),
|
||||
makeDecision({ id: 'dec-other', approverId: 'user2', status: 'PENDING' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const harness = renderApprovals({ currentUserId: 'user1' });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.myPendingDecision).toBeNull();
|
||||
});
|
||||
|
||||
it('never offers a decision to an anonymous viewer', async () => {
|
||||
const harness = renderApprovals({ currentUserId: null });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
|
||||
expect(harness.result.current.activePendingRequest?.id).toBe('req1');
|
||||
expect(harness.result.current.myPendingDecision).toBeNull();
|
||||
});
|
||||
|
||||
it('lets a caller clear the error banner by hand', async () => {
|
||||
fetchMock.mockResolvedValue(fail(500, { error: 'Boom' }));
|
||||
const harness = renderApprovals();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchRequests();
|
||||
});
|
||||
expect(harness.result.current.error).toBe('Boom');
|
||||
|
||||
act(() => harness.result.current.setError(''));
|
||||
expect(harness.result.current.error).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { useDownloadActions } from '@/components/video-page/hooks/use-download-actions';
|
||||
import type { Comment, Version, VideoData } from '@/components/video-page/types';
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastCustom = vi.fn();
|
||||
const toastDismiss = vi.fn();
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => toastError(...args),
|
||||
custom: (...args: unknown[]) => toastCustom(...args),
|
||||
dismiss: (...args: unknown[]) => toastDismiss(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
type Params = Parameters<typeof useDownloadActions>[0];
|
||||
|
||||
const VERSION_ID = 'ver1';
|
||||
const BUNNY_HOST = 'cdn.example.test';
|
||||
const ALLOWED_DIRECT_HOST = 'files.example.test';
|
||||
/** Just over the 10 GiB ceiling in lib/client/download-file.ts. */
|
||||
const OVERSIZED_BYTES = String(11 * 1024 * 1024 * 1024);
|
||||
|
||||
function makeVersion(overrides: Partial<Version> = {}): Version & { comments: Comment[] } {
|
||||
return {
|
||||
id: VERSION_ID,
|
||||
versionNumber: 1,
|
||||
versionLabel: null,
|
||||
providerId: 'bunny',
|
||||
videoId: 'vid1',
|
||||
originalUrl: `https://${BUNNY_HOST}/abc/play.mp4`,
|
||||
title: null,
|
||||
thumbnailUrl: null,
|
||||
duration: 600,
|
||||
isActive: true,
|
||||
_count: { comments: 0 },
|
||||
comments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeVideo(overrides: Partial<VideoData> = {}): VideoData {
|
||||
return {
|
||||
id: 'vid1',
|
||||
title: 'Cut 3',
|
||||
description: null,
|
||||
projectId: 'proj1',
|
||||
project: { name: 'Ad campaign', ownerId: 'user1' },
|
||||
versions: [],
|
||||
isAuthenticated: true,
|
||||
currentUserId: 'user1',
|
||||
currentUserName: 'Ada',
|
||||
canDownload: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface FileResponseInit {
|
||||
ok?: boolean;
|
||||
contentLength?: string | null;
|
||||
contentType?: string | null;
|
||||
}
|
||||
|
||||
/** What the CDN answers when the bytes are pulled for renaming. */
|
||||
function fileResponse({
|
||||
ok = true,
|
||||
contentLength = '2048',
|
||||
contentType = 'video/mp4',
|
||||
}: FileResponseInit = {}) {
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 502,
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
if (name === 'content-length') return contentLength;
|
||||
if (name === 'content-type') return contentType;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
// Null body sends downloadNamedFile down its res.blob() path, which is what
|
||||
// a jsdom fetch mock can honestly represent.
|
||||
body: null,
|
||||
blob: () => Promise.resolve(new Blob(['bytes'])),
|
||||
json: () => Promise.reject(new SyntaxError('not json')),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareResponse(ok: boolean, payload: unknown = {}) {
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 404,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let clicked: { href: string; download: string }[];
|
||||
/** The response the byte-pulling fetch answers with; reassign per test. */
|
||||
let downloadResponse: ReturnType<typeof fileResponse>;
|
||||
|
||||
type Harness = RenderHookResult<ReturnType<typeof useDownloadActions>, Params>;
|
||||
|
||||
function renderDownload(overrides: Partial<Params> = {}): Harness {
|
||||
const initialProps: Params = {
|
||||
activeVersion: makeVersion(),
|
||||
video: makeVideo(),
|
||||
...overrides,
|
||||
};
|
||||
return renderHook((props: Params) => useDownloadActions(props), { initialProps });
|
||||
}
|
||||
|
||||
function urlsFetched(): string[] {
|
||||
return fetchMock.mock.calls.map((call) => call[0] as string);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', `https://${BUNNY_HOST}`);
|
||||
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', ALLOWED_DIRECT_HOST);
|
||||
clicked = [];
|
||||
downloadResponse = fileResponse();
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
if (typeof url === 'string' && url.includes('prepare=1')) {
|
||||
return Promise.resolve(prepareResponse(true, { data: {} }));
|
||||
}
|
||||
return Promise.resolve(downloadResponse);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
// jsdom would try to navigate on a real anchor click. Record the anchor
|
||||
// instead: its href and download attribute are the whole observable result.
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement
|
||||
) {
|
||||
clicked.push({ href: this.getAttribute('href') ?? '', download: this.download });
|
||||
});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
toastError.mockReset();
|
||||
toastCustom.mockReset();
|
||||
toastDismiss.mockReset();
|
||||
});
|
||||
|
||||
describe('useDownloadActions refusing to start', () => {
|
||||
it('does nothing before a version is loaded', async () => {
|
||||
const harness = renderDownload({ activeVersion: undefined });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing before the video is loaded', async () => {
|
||||
const harness = renderDownload({ video: null });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when the share link has downloads switched off', async () => {
|
||||
const harness = renderDownload({ video: makeVideo({ canDownload: false }) });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Download is disabled for this shared link');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// canDownload is optional on VideoData, and the guard is a plain falsy check,
|
||||
// so a payload that never mentions the flag is treated as "no downloads".
|
||||
it('refuses when the payload never mentioned canDownload', async () => {
|
||||
const video = makeVideo();
|
||||
delete video.canDownload;
|
||||
const harness = renderDownload({ video });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Download is disabled for this shared link');
|
||||
});
|
||||
|
||||
it('refuses a provider with no direct file behind it', async () => {
|
||||
const harness = renderDownload({
|
||||
activeVersion: makeVersion({ providerId: 'youtube', originalUrl: 'https://youtu.be/abc' }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('This video source does not support direct download');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDownloadActions from Bunny', () => {
|
||||
it('asks the route to prepare the file before pulling it', async () => {
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload('compressed');
|
||||
});
|
||||
|
||||
expect(urlsFetched()).toEqual([
|
||||
`/api/versions/${VERSION_ID}/download?source=compressed&prepare=1`,
|
||||
`/api/versions/${VERSION_ID}/download?source=compressed`,
|
||||
]);
|
||||
expect(fetchMock.mock.calls[0][1]).toEqual({ cache: 'no-store' });
|
||||
expect(clicked).toEqual([{ href: 'blob:openframe-test', download: 'Cut 3 v1.mp4' }]);
|
||||
});
|
||||
|
||||
it('carries the original preference through both requests', async () => {
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload('original');
|
||||
});
|
||||
|
||||
expect(urlsFetched()).toEqual([
|
||||
`/api/versions/${VERSION_ID}/download?source=original&prepare=1`,
|
||||
`/api/versions/${VERSION_ID}/download?source=original`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults to the compressed file when no preference is given', async () => {
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(urlsFetched()[0]).toContain('source=compressed');
|
||||
});
|
||||
|
||||
it('reports which file it is fetching while the download runs', async () => {
|
||||
const pending = deferred<unknown>();
|
||||
fetchMock.mockReturnValueOnce(pending.promise);
|
||||
const harness = renderDownload();
|
||||
|
||||
let started: Promise<void> | undefined;
|
||||
act(() => {
|
||||
started = harness.result.current.startDownload('original');
|
||||
});
|
||||
|
||||
expect(harness.result.current.activeDownloadTarget).toBe('original');
|
||||
expect(harness.result.current.isDownloadingVideo).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve(prepareResponse(true, { data: {} }));
|
||||
await started;
|
||||
});
|
||||
|
||||
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
||||
expect(harness.result.current.isDownloadingVideo).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the message the route sent when the file is not ready', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
prepareResponse(false, { error: 'Original file is still processing' })
|
||||
);
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload('original');
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Original file is still processing');
|
||||
expect(clicked).toEqual([]);
|
||||
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('names the missing original when the failure body says nothing', async () => {
|
||||
fetchMock.mockResolvedValueOnce(prepareResponse(false, {}));
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload('original');
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Original file is not available for this video');
|
||||
});
|
||||
|
||||
it('names the missing compressed file when the failure body says nothing', async () => {
|
||||
fetchMock.mockResolvedValueOnce(prepareResponse(false, {}));
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload('compressed');
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Compressed file is not available for this video');
|
||||
});
|
||||
|
||||
it('clears the progress panel before showing the error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(prepareResponse(false, { error: 'Nope' }));
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
// The prepare step fails before the panel is opened, so nothing to dismiss.
|
||||
expect(toastCustom).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('Nope');
|
||||
});
|
||||
|
||||
it('opens a progress panel and leaves a success message behind', async () => {
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
// One render for the initial panel, more as progress and success arrive.
|
||||
expect(toastCustom).toHaveBeenCalled();
|
||||
expect(toastCustom.mock.calls[0][1]).toMatchObject({ id: `download-${VERSION_ID}` });
|
||||
expect(toastDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a plain navigation for a file too large to rename', async () => {
|
||||
downloadResponse = fileResponse({ contentLength: OVERSIZED_BYTES });
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastDismiss).toHaveBeenCalledWith(`download-${VERSION_ID}`);
|
||||
// Cross-origin, so no download attribute: the CDN picks the filename.
|
||||
expect(clicked).toEqual([
|
||||
{ href: `/api/versions/${VERSION_ID}/download?source=compressed`, download: '' },
|
||||
]);
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a plain navigation when the CDN refuses the byte request', async () => {
|
||||
downloadResponse = fileResponse({ ok: false });
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(clicked).toHaveLength(1);
|
||||
expect(clicked[0].download).toBe('');
|
||||
expect(toastDismiss).toHaveBeenCalledWith(`download-${VERSION_ID}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDownloadActions naming the file', () => {
|
||||
it('uses the version label when the editor set one', async () => {
|
||||
const harness = renderDownload({
|
||||
activeVersion: makeVersion({ versionLabel: ' Client cut ', versionNumber: 4 }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(clicked[0].download).toBe('Cut 3 Client cut.mp4');
|
||||
});
|
||||
|
||||
it('falls back to the version number when there is no label', async () => {
|
||||
const harness = renderDownload({ activeVersion: makeVersion({ versionNumber: 7 }) });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(clicked[0].download).toBe('Cut 3 v7.mp4');
|
||||
});
|
||||
|
||||
it('strips path separators and other characters a filesystem rejects', async () => {
|
||||
const harness = renderDownload({ video: makeVideo({ title: 'Q3/Q4: "final" cut' }) });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(clicked[0].download).toBe('Q3-Q4- -final- cut v1.mp4');
|
||||
});
|
||||
|
||||
// The `|| 'video'` fallback in the hook is unreachable in practice:
|
||||
// sanitising replaces forbidden characters with '-' instead of dropping them,
|
||||
// and the "v<number>" suffix survives any title. Pinned so that a rewrite of
|
||||
// sanitizeDownloadFileName has to decide about it deliberately.
|
||||
it('still produces a name when the title is nothing but separators', async () => {
|
||||
const harness = renderDownload({ video: makeVideo({ title: '///' }) });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(clicked[0].download).toBe('--- v1.mp4');
|
||||
});
|
||||
|
||||
it('takes the extension from the content type the CDN reported', async () => {
|
||||
downloadResponse = fileResponse({ contentType: 'video/quicktime' });
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(clicked[0].download).toBe('Cut 3 v1.mov');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDownloadActions from R2', () => {
|
||||
const r2Version = makeVersion({
|
||||
providerId: 'r2',
|
||||
originalUrl: '/api/upload/video/proj1/clip.webm',
|
||||
});
|
||||
|
||||
it('navigates to the same-origin proxy with the download attribute set', async () => {
|
||||
const harness = renderDownload({ activeVersion: r2Version });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
// No prepare step and no byte pulling: the proxy streams it.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(clicked).toEqual([
|
||||
{ href: '/api/upload/video/proj1/clip.webm', download: 'Cut 3 v1.webm' },
|
||||
]);
|
||||
expect(toastCustom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The R2 branch never awaits, so the busy flag is set and cleared inside one
|
||||
// batch: the button never renders as downloading. That is correct here (the
|
||||
// browser takes over immediately) but it means the target is unobservable.
|
||||
it('never renders as busy because the R2 branch never awaits', async () => {
|
||||
const harness = renderDownload({ activeVersion: r2Version });
|
||||
|
||||
let started: Promise<void> | undefined;
|
||||
act(() => {
|
||||
started = harness.result.current.startDownload('original');
|
||||
});
|
||||
|
||||
expect(harness.result.current.activeDownloadTarget).toBeNull();
|
||||
expect(clicked).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await started;
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults the extension to mp4 when the proxy path has none', async () => {
|
||||
const harness = renderDownload({
|
||||
activeVersion: makeVersion({ providerId: 'r2', originalUrl: '/api/upload/video/proj1/clip' }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(clicked[0].download).toBe('Cut 3 v1.mp4');
|
||||
});
|
||||
|
||||
it('refuses an R2 version whose URL is not the media proxy', async () => {
|
||||
const harness = renderDownload({
|
||||
activeVersion: makeVersion({
|
||||
providerId: 'r2',
|
||||
originalUrl: 'https://evil.example.test/clip.mp4',
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
||||
expect(clicked).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDownloadActions from a direct host', () => {
|
||||
function directVersion(url: string) {
|
||||
return makeVersion({ providerId: 'direct', originalUrl: url });
|
||||
}
|
||||
|
||||
it('pulls the bytes from a host on the allow list', async () => {
|
||||
downloadResponse = fileResponse({ contentType: null });
|
||||
const harness = renderDownload({
|
||||
activeVersion: directVersion(`https://${ALLOWED_DIRECT_HOST}/clip.mov`),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(urlsFetched()).toEqual([`https://${ALLOWED_DIRECT_HOST}/clip.mov`]);
|
||||
expect(clicked).toEqual([{ href: 'blob:openframe-test', download: 'Cut 3 v1.mov' }]);
|
||||
});
|
||||
|
||||
it('accepts the Bunny CDN hostname without it being listed explicitly', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', '');
|
||||
const harness = renderDownload({
|
||||
activeVersion: directVersion(`https://${BUNNY_HOST}/clip.mp4`),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
expect(clicked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a host that is not on the allow list', async () => {
|
||||
const harness = renderDownload({
|
||||
activeVersion: directVersion('https://evil.example.test/clip.mp4'),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses every host when neither allow list is configured', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', '');
|
||||
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', '');
|
||||
const harness = renderDownload({
|
||||
activeVersion: directVersion(`https://${ALLOWED_DIRECT_HOST}/clip.mp4`),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
||||
});
|
||||
|
||||
it('refuses a non-http scheme even on an allowed host', async () => {
|
||||
const harness = renderDownload({
|
||||
activeVersion: directVersion(`javascript:alert(1)//${ALLOWED_DIRECT_HOST}`),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
||||
expect(clicked).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a URL that does not parse at all', async () => {
|
||||
const harness = renderDownload({ activeVersion: directVersion('not a url') });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('This direct download host is not allowed');
|
||||
});
|
||||
|
||||
it('matches the host case-insensitively', async () => {
|
||||
const harness = renderDownload({
|
||||
activeVersion: directVersion(`https://FILES.EXAMPLE.TEST/clip.mp4`),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
expect(clicked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDownloadActions repeated clicks', () => {
|
||||
it('ignores a second click once the button has re-rendered as busy', async () => {
|
||||
const pending = deferred<unknown>();
|
||||
fetchMock.mockReturnValueOnce(pending.promise);
|
||||
const harness = renderDownload();
|
||||
|
||||
let first: Promise<void> | undefined;
|
||||
act(() => {
|
||||
first = harness.result.current.startDownload();
|
||||
});
|
||||
expect(harness.result.current.isDownloadingVideo).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve(prepareResponse(true, { data: {} }));
|
||||
await first;
|
||||
});
|
||||
});
|
||||
|
||||
// KNOWN FRAGILITY, pinned rather than fixed. The in-flight guard reads
|
||||
// `isDownloadingVideo` out of the closure the callback was created in, so two
|
||||
// calls made from the SAME render (a double click landing before React
|
||||
// commits the state update) both get through and the file is fetched twice.
|
||||
it('lets two calls from the same render both through', async () => {
|
||||
const startDownload = renderDownload().result.current.startDownload;
|
||||
|
||||
await act(async () => {
|
||||
await Promise.all([startDownload(), startDownload()]);
|
||||
});
|
||||
|
||||
expect(urlsFetched().filter((url) => url.includes('prepare=1'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('is ready to download again after a failure', async () => {
|
||||
fetchMock.mockResolvedValueOnce(prepareResponse(false, { error: 'Nope' }));
|
||||
const harness = renderDownload();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
expect(harness.result.current.isDownloadingVideo).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.startDownload();
|
||||
});
|
||||
expect(clicked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,771 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { useVersionActions } from '@/components/video-page/hooks/use-version-actions';
|
||||
import type { Comment, Version, VideoData } from '@/components/video-page/types';
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => toastError(...args),
|
||||
success: (...args: unknown[]) => toastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
interface FakeTusOptions {
|
||||
endpoint: string;
|
||||
headers: Record<string, string>;
|
||||
metadata: Record<string, string>;
|
||||
onError: (error: Error) => void;
|
||||
onProgress: (bytesUploaded: number, bytesTotal: number) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/** Every tus upload the hook constructed, and how the fake client behaves. */
|
||||
const tusUploads: { fileName: string; options: FakeTusOptions }[] = [];
|
||||
let tusFailure: string | null = null;
|
||||
|
||||
// tus-js-client talks to Bunny over the network. The fake keeps the callback
|
||||
// contract (onProgress then onSuccess, or onError) and nothing else.
|
||||
vi.mock('tus-js-client', () => ({
|
||||
Upload: class FakeUpload {
|
||||
private options: FakeTusOptions;
|
||||
|
||||
constructor(file: File, options: FakeTusOptions) {
|
||||
this.options = options;
|
||||
tusUploads.push({ fileName: file.name, options });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (tusFailure) {
|
||||
this.options.onError(new Error(tusFailure));
|
||||
return;
|
||||
}
|
||||
this.options.onProgress(512, 1024);
|
||||
this.options.onSuccess();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const uploadVideoToR2 = vi.fn();
|
||||
const cleanupPendingR2VideoUpload = vi.fn();
|
||||
|
||||
// The R2 client does presigning and multipart PUTs over XHR: a boundary, not a
|
||||
// helper of this hook.
|
||||
vi.mock('@/lib/client/r2-video-upload', () => ({
|
||||
uploadVideoToR2: (...args: unknown[]) => uploadVideoToR2(...args),
|
||||
cleanupPendingR2VideoUpload: (...args: unknown[]) => cleanupPendingR2VideoUpload(...args),
|
||||
}));
|
||||
|
||||
type Params = Parameters<typeof useVersionActions>[0];
|
||||
type HookParams = Omit<Params, 'setVideo' | 'activeVersionId' | 'setActiveVersionId'>;
|
||||
|
||||
const PROJECT_ID = 'proj1';
|
||||
const VIDEO_ID = 'vid1';
|
||||
const VERSIONS_URL = `/api/projects/${PROJECT_ID}/videos/${VIDEO_ID}/versions`;
|
||||
const BUNNY_INIT_URL = `/api/projects/${PROJECT_ID}/videos/bunny-init`;
|
||||
const DIRECT_URL = 'https://files.example.test/clip.mp4';
|
||||
|
||||
function makeVersion(overrides: Partial<Version> = {}): Version & { comments: Comment[] } {
|
||||
return {
|
||||
id: 'ver1',
|
||||
versionNumber: 1,
|
||||
versionLabel: null,
|
||||
providerId: 'direct',
|
||||
videoId: VIDEO_ID,
|
||||
originalUrl: 'https://files.example.test/v1.mp4',
|
||||
title: null,
|
||||
thumbnailUrl: null,
|
||||
duration: 600,
|
||||
isActive: true,
|
||||
_count: { comments: 0 },
|
||||
comments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeVideo(): VideoData {
|
||||
return {
|
||||
id: VIDEO_ID,
|
||||
title: 'Cut 3',
|
||||
description: null,
|
||||
projectId: PROJECT_ID,
|
||||
project: { name: 'Ad campaign', ownerId: 'user1' },
|
||||
isAuthenticated: true,
|
||||
currentUserId: 'user1',
|
||||
currentUserName: 'Ada',
|
||||
// ver1 is the one on screen; ver3 is the row the server has flagged active.
|
||||
versions: [
|
||||
makeVersion({ isActive: false }),
|
||||
makeVersion({ id: 'ver2', versionNumber: 2, isActive: false }),
|
||||
makeVersion({ id: 'ver3', versionNumber: 3, isActive: true }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** What the versions POST answers with on success. */
|
||||
const createdVersion = {
|
||||
id: 'ver-new',
|
||||
versionNumber: 4,
|
||||
versionLabel: null,
|
||||
providerId: 'direct',
|
||||
videoId: VIDEO_ID,
|
||||
originalUrl: DIRECT_URL,
|
||||
title: null,
|
||||
thumbnailUrl: '/placeholder-video-thumbnail.png',
|
||||
duration: null,
|
||||
isActive: true,
|
||||
_count: { comments: 0 },
|
||||
};
|
||||
|
||||
function ok(payload: unknown) {
|
||||
return { ok: true, status: 200, json: () => Promise.resolve(payload) };
|
||||
}
|
||||
|
||||
function fail(status: number, payload: unknown = {}) {
|
||||
return { ok: false, status, json: () => Promise.resolve(payload) };
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function callsTo(url: string, method?: string) {
|
||||
return fetchMock.mock.calls.filter(
|
||||
(call) => call[0] === url && (call[1]?.method ?? undefined) === method
|
||||
);
|
||||
}
|
||||
|
||||
function bodyOf(call: unknown[]): unknown {
|
||||
return JSON.parse((call[1] as { body: string }).body);
|
||||
}
|
||||
|
||||
function useHarness(overrides: Partial<HookParams>) {
|
||||
const [video, setVideo] = useState<VideoData | null>(makeVideo());
|
||||
const [activeVersionId, setActiveVersionId] = useState<string | null>('ver1');
|
||||
const actions = useVersionActions({
|
||||
projectId: PROJECT_ID,
|
||||
videoId: VIDEO_ID,
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
...overrides,
|
||||
});
|
||||
return { video, activeVersionId, actions };
|
||||
}
|
||||
|
||||
type Harness = RenderHookResult<ReturnType<typeof useHarness>, Partial<HookParams>>;
|
||||
|
||||
function renderVersionActions(overrides: Partial<HookParams> = {}): Harness {
|
||||
return renderHook((props: Partial<HookParams>) => useHarness(props), {
|
||||
initialProps: overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function versionIds(harness: Harness): string[] {
|
||||
return (harness.result.current.video?.versions ?? []).map((v) => v.id);
|
||||
}
|
||||
|
||||
function makeFile(name = 'my clip.mp4') {
|
||||
return new File(['0123456789'], name, { type: 'video/mp4' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tusUploads.length = 0;
|
||||
tusFailure = null;
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://cdn.example.test');
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
if (url === BUNNY_INIT_URL) {
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
data: {
|
||||
videoId: 'bunny-vid',
|
||||
libraryId: '1234',
|
||||
signature: 'sig',
|
||||
expirationTime: 1800000000,
|
||||
uploadToken: 'upload-token',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === VERSIONS_URL) {
|
||||
return Promise.resolve(ok({ data: createdVersion }));
|
||||
}
|
||||
return Promise.resolve(ok({ data: {} }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
uploadVideoToR2.mockResolvedValue({
|
||||
proxyUrl: '/api/upload/video/proj1/clip.mp4',
|
||||
objectKey: 'proj1/clip.mp4',
|
||||
uploadToken: 'r2-upload-token',
|
||||
reservationId: 'res1',
|
||||
thumbnailObjectKey: 'proj1/clip.jpg',
|
||||
thumbnailUrl: '/api/upload/image/proj1/clip.jpg',
|
||||
duration: 42,
|
||||
});
|
||||
cleanupPendingR2VideoUpload.mockResolvedValue(undefined);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
toastError.mockReset();
|
||||
toastSuccess.mockReset();
|
||||
uploadVideoToR2.mockReset();
|
||||
cleanupPendingR2VideoUpload.mockReset();
|
||||
});
|
||||
|
||||
describe('useVersionActions typing a URL', () => {
|
||||
it('recognises a supported URL and clears any earlier complaint', () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
act(() => harness.result.current.actions.handleNewVersionUrlChange(DIRECT_URL));
|
||||
|
||||
expect(harness.result.current.actions.newVersionUrl).toBe(DIRECT_URL);
|
||||
expect(harness.result.current.actions.newVersionSource).toEqual({
|
||||
providerId: 'direct',
|
||||
videoId: DIRECT_URL,
|
||||
originalUrl: DIRECT_URL,
|
||||
});
|
||||
expect(harness.result.current.actions.newVersionUrlError).toBe('');
|
||||
});
|
||||
|
||||
it('complains once the unrecognised URL is long enough to be a real attempt', () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
act(() =>
|
||||
harness.result.current.actions.handleNewVersionUrlChange('https://example.test/not-a-video')
|
||||
);
|
||||
|
||||
expect(harness.result.current.actions.newVersionSource).toBeNull();
|
||||
expect(harness.result.current.actions.newVersionUrlError).toBe('Unsupported URL');
|
||||
});
|
||||
|
||||
it('stays quiet while the field holds fewer than eleven characters', () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
act(() => harness.result.current.actions.handleNewVersionUrlChange('https://ex'));
|
||||
|
||||
expect(harness.result.current.actions.newVersionUrlError).toBe('');
|
||||
expect(harness.result.current.actions.newVersionSource).toBeNull();
|
||||
});
|
||||
|
||||
it('resets the source when the field is emptied again', () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
act(() => harness.result.current.actions.handleNewVersionUrlChange(DIRECT_URL));
|
||||
act(() => harness.result.current.actions.handleNewVersionUrlChange(' '));
|
||||
|
||||
expect(harness.result.current.actions.newVersionSource).toBeNull();
|
||||
expect(harness.result.current.actions.newVersionUrlError).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVersionActions creating a version from a URL', () => {
|
||||
async function createFromUrl(harness: Harness, url = DIRECT_URL) {
|
||||
act(() => harness.result.current.actions.handleNewVersionUrlChange(url));
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleCreateVersion();
|
||||
});
|
||||
}
|
||||
|
||||
it('posts the parsed source and the derived thumbnail to the versions route', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
const post = callsTo(VERSIONS_URL, 'POST')[0];
|
||||
expect(bodyOf(post)).toEqual({
|
||||
videoUrl: DIRECT_URL,
|
||||
providerId: 'direct',
|
||||
providerVideoId: DIRECT_URL,
|
||||
uploadToken: null,
|
||||
objectKey: null,
|
||||
reservationId: null,
|
||||
versionLabel: null,
|
||||
thumbnailUrl: '/placeholder-video-thumbnail.png',
|
||||
duration: null,
|
||||
setActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('puts the new version first and demotes every other one', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
expect(versionIds(harness)).toEqual(['ver-new', 'ver1', 'ver2', 'ver3']);
|
||||
const versions = harness.result.current.video?.versions ?? [];
|
||||
expect(versions.map((v) => v.isActive)).toEqual([true, false, false, false]);
|
||||
expect(versions[0].comments).toEqual([]);
|
||||
expect(harness.result.current.activeVersionId).toBe('ver-new');
|
||||
});
|
||||
|
||||
it('closes the dialog and empties the form once the version exists', async () => {
|
||||
const harness = renderVersionActions();
|
||||
act(() => harness.result.current.actions.setShowVersionDialog(true));
|
||||
act(() => harness.result.current.actions.setNewVersionLabel('Client cut'));
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
expect(harness.result.current.actions.showVersionDialog).toBe(false);
|
||||
expect(harness.result.current.actions.newVersionUrl).toBe('');
|
||||
expect(harness.result.current.actions.newVersionLabel).toBe('');
|
||||
expect(harness.result.current.actions.newVersionSource).toBeNull();
|
||||
expect(harness.result.current.actions.newVersionFile).toBeNull();
|
||||
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
|
||||
});
|
||||
|
||||
it('sends a trimmed label when the editor typed one', async () => {
|
||||
const harness = renderVersionActions();
|
||||
act(() => harness.result.current.actions.setNewVersionLabel(' Client cut '));
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toMatchObject({
|
||||
versionLabel: 'Client cut',
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing at all without a project', async () => {
|
||||
const harness = renderVersionActions({ projectId: undefined });
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a URL no provider recognised', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await createFromUrl(harness, 'https://example.test/not-a-video');
|
||||
|
||||
expect(callsTo(VERSIONS_URL, 'POST')).toHaveLength(0);
|
||||
expect(toastError).toHaveBeenCalledWith('Invalid URL');
|
||||
});
|
||||
|
||||
it('leaves the version list and the dialog untouched when the server refuses', async () => {
|
||||
fetchMock.mockResolvedValue(fail(403, { error: 'Only editors can add versions' }));
|
||||
const harness = renderVersionActions();
|
||||
act(() => harness.result.current.actions.setShowVersionDialog(true));
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
|
||||
expect(harness.result.current.activeVersionId).toBe('ver1');
|
||||
expect(harness.result.current.actions.showVersionDialog).toBe(true);
|
||||
expect(toastError).toHaveBeenCalledWith('Only editors can add versions');
|
||||
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a generic message when the failure body says nothing', async () => {
|
||||
fetchMock.mockResolvedValue(fail(500, {}));
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to create version');
|
||||
});
|
||||
|
||||
it('reports a network failure without leaving the dialog spinning', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await createFromUrl(harness);
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('offline');
|
||||
expect(harness.result.current.actions.isCreatingVersion).toBe(false);
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVersionActions uploading a file to Bunny', () => {
|
||||
async function createFromFile(harness: Harness, file = makeFile()) {
|
||||
act(() => {
|
||||
harness.result.current.actions.setNewVersionMode('file');
|
||||
harness.result.current.actions.setNewVersionFile(file);
|
||||
});
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleCreateVersion();
|
||||
});
|
||||
}
|
||||
|
||||
it('refuses the file tab when the host has direct uploads switched off', async () => {
|
||||
const harness = renderVersionActions({ directUploadsEnabled: false });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Direct uploads are disabled by this host');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to upload nothing', async () => {
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
act(() => harness.result.current.actions.setNewVersionMode('file'));
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleCreateVersion();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('No file selected');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initialises the Bunny upload with the filename minus its extension', async () => {
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'my clip' });
|
||||
expect(tusUploads[0].options.endpoint).toBe('https://video.bunnycdn.com/tusupload');
|
||||
expect(tusUploads[0].options.headers).toEqual({
|
||||
AuthorizationSignature: 'sig',
|
||||
AuthorizationExpire: '1800000000',
|
||||
VideoId: 'bunny-vid',
|
||||
LibraryId: '1234',
|
||||
});
|
||||
expect(tusUploads[0].options.metadata).toEqual({ filetype: 'video/mp4', title: 'my clip' });
|
||||
});
|
||||
|
||||
it('prefers the version label over the filename as the Bunny title', async () => {
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
act(() => harness.result.current.actions.setNewVersionLabel(' Client cut '));
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'Client cut' });
|
||||
});
|
||||
|
||||
it('registers the version against the Bunny embed and CDN thumbnail', async () => {
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toEqual({
|
||||
videoUrl: 'https://iframe.mediadelivery.net/embed/1234/bunny-vid',
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-vid',
|
||||
uploadToken: 'upload-token',
|
||||
objectKey: null,
|
||||
reservationId: null,
|
||||
versionLabel: null,
|
||||
thumbnailUrl: 'https://cdn.example.test/bunny-vid/thumbnail.jpg',
|
||||
duration: null,
|
||||
setActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends no thumbnail when no CDN hostname is configured', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', '');
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toMatchObject({ thumbnailUrl: null });
|
||||
});
|
||||
|
||||
it('reports upload progress and then resets it', async () => {
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(harness.result.current.actions.newVersionUploadProgress).toBe(0);
|
||||
expect(harness.result.current.actions.newVersionUploadStatus).toBe('');
|
||||
});
|
||||
|
||||
it('surfaces a failed initialisation and never starts a tus upload', async () => {
|
||||
fetchMock.mockResolvedValueOnce(fail(500, {}));
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(tusUploads).toHaveLength(0);
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to initialize upload');
|
||||
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// This is the rollback path: the bytes are already on Bunny when the versions
|
||||
// route rejects, so the pending video has to be handed back.
|
||||
it('deletes the pending Bunny video when the version cannot be registered', async () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url === VERSIONS_URL) return Promise.resolve(fail(507, { error: 'Storage full' }));
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
data: {
|
||||
videoId: 'bunny-vid',
|
||||
libraryId: '1234',
|
||||
signature: 'sig',
|
||||
expirationTime: 1800000000,
|
||||
uploadToken: 'upload-token',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
const cleanup = callsTo(BUNNY_INIT_URL, 'DELETE')[0];
|
||||
expect(bodyOf(cleanup)).toEqual({ videoId: 'bunny-vid', uploadToken: 'upload-token' });
|
||||
expect(toastError).toHaveBeenCalledWith('Storage full');
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
|
||||
});
|
||||
|
||||
it('does not delete anything after a version was created successfully', async () => {
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// BUG, pinned rather than fixed. bunny-init has already created a video on
|
||||
// Bunny by the time tus runs, but `pendingCleanup` is only assigned after
|
||||
// uploadNewVersionFile returns. A tus failure therefore leaks that video:
|
||||
// nothing ever calls the DELETE branch below it in the catch.
|
||||
it('leaks the Bunny video when the tus upload itself fails', async () => {
|
||||
tusFailure = 'connection reset';
|
||||
const harness = renderVersionActions({ directUploadsEnabled: true });
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Upload failed: connection reset');
|
||||
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVersionActions uploading a file to R2', () => {
|
||||
async function createFromFile(harness: Harness) {
|
||||
act(() => {
|
||||
harness.result.current.actions.setNewVersionMode('file');
|
||||
harness.result.current.actions.setNewVersionFile(makeFile());
|
||||
});
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleCreateVersion();
|
||||
});
|
||||
}
|
||||
|
||||
it('registers the version against the proxy URL and object key', async () => {
|
||||
const harness = renderVersionActions({
|
||||
directUploadsEnabled: true,
|
||||
directUploadProvider: 'r2',
|
||||
});
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(uploadVideoToR2).toHaveBeenCalledWith(PROJECT_ID, expect.any(File), expect.anything());
|
||||
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toEqual({
|
||||
videoUrl: '/api/upload/video/proj1/clip.mp4',
|
||||
providerId: 'r2',
|
||||
providerVideoId: 'proj1/clip.mp4',
|
||||
uploadToken: 'r2-upload-token',
|
||||
objectKey: 'proj1/clip.mp4',
|
||||
reservationId: 'res1',
|
||||
versionLabel: null,
|
||||
thumbnailUrl: '/api/upload/image/proj1/clip.jpg',
|
||||
duration: 42,
|
||||
setActive: true,
|
||||
});
|
||||
expect(tusUploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to the placeholder thumbnail when none was captured', async () => {
|
||||
uploadVideoToR2.mockResolvedValue({
|
||||
proxyUrl: '/api/upload/video/proj1/clip.mp4',
|
||||
objectKey: 'proj1/clip.mp4',
|
||||
uploadToken: 'r2-upload-token',
|
||||
reservationId: null,
|
||||
thumbnailObjectKey: null,
|
||||
thumbnailUrl: null,
|
||||
duration: null,
|
||||
});
|
||||
const harness = renderVersionActions({
|
||||
directUploadsEnabled: true,
|
||||
directUploadProvider: 'r2',
|
||||
});
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(bodyOf(callsTo(VERSIONS_URL, 'POST')[0])).toMatchObject({
|
||||
thumbnailUrl: '/placeholder-video-thumbnail.png',
|
||||
});
|
||||
});
|
||||
|
||||
// The rollback path for R2: the object is in the bucket before the version
|
||||
// row exists, so a rejected POST has to release it and its reservation.
|
||||
it('releases the uploaded object when the version cannot be registered', async () => {
|
||||
fetchMock.mockResolvedValue(fail(500, { error: 'Database unavailable' }));
|
||||
const harness = renderVersionActions({
|
||||
directUploadsEnabled: true,
|
||||
directUploadProvider: 'r2',
|
||||
});
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(cleanupPendingR2VideoUpload).toHaveBeenCalledWith(PROJECT_ID, {
|
||||
objectKey: 'proj1/clip.mp4',
|
||||
uploadToken: 'r2-upload-token',
|
||||
reservationId: 'res1',
|
||||
thumbnailObjectKey: 'proj1/clip.jpg',
|
||||
});
|
||||
expect(toastError).toHaveBeenCalledWith('Database unavailable');
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
|
||||
});
|
||||
|
||||
it('releases nothing when the upload itself never finished', async () => {
|
||||
uploadVideoToR2.mockRejectedValue(new Error('Upload aborted'));
|
||||
const harness = renderVersionActions({
|
||||
directUploadsEnabled: true,
|
||||
directUploadProvider: 'r2',
|
||||
});
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(cleanupPendingR2VideoUpload).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('Upload aborted');
|
||||
});
|
||||
|
||||
it('surfaces the upload progress the client reports', async () => {
|
||||
let report: ((progress: number) => void) | undefined;
|
||||
uploadVideoToR2.mockImplementation(
|
||||
(_projectId: string, _file: File, options: { onProgress: (p: number) => void }) => {
|
||||
report = options.onProgress;
|
||||
return new Promise(() => {});
|
||||
}
|
||||
);
|
||||
const harness = renderVersionActions({
|
||||
directUploadsEnabled: true,
|
||||
directUploadProvider: 'r2',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
harness.result.current.actions.setNewVersionMode('file');
|
||||
harness.result.current.actions.setNewVersionFile(makeFile());
|
||||
});
|
||||
act(() => {
|
||||
void harness.result.current.actions.handleCreateVersion();
|
||||
});
|
||||
act(() => report?.(37));
|
||||
|
||||
expect(harness.result.current.actions.newVersionUploadProgress).toBe(37);
|
||||
expect(harness.result.current.actions.newVersionUploadStatus).toBe('Uploading... 37%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVersionActions deleting a version', () => {
|
||||
async function deleteVersion(harness: Harness, versionId: string) {
|
||||
act(() => {
|
||||
harness.result.current.actions.setVersionToDelete(versionId);
|
||||
harness.result.current.actions.setShowDeleteVersionDialog(true);
|
||||
});
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleDeleteVersion();
|
||||
});
|
||||
}
|
||||
|
||||
it('does nothing when no version was picked', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleDeleteVersion();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing without a project', async () => {
|
||||
const harness = renderVersionActions({ projectId: undefined });
|
||||
|
||||
await deleteVersion(harness, 'ver2');
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes through the project-scoped route and drops the version', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver2');
|
||||
|
||||
expect(callsTo(`${VERSIONS_URL}/ver2`, 'DELETE')).toHaveLength(1);
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver3']);
|
||||
expect(harness.result.current.actions.showDeleteVersionDialog).toBe(false);
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Version deleted');
|
||||
expect(harness.result.current.actions.isDeletingVersion).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the selection alone when a version other than the open one goes', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver2');
|
||||
|
||||
expect(harness.result.current.activeVersionId).toBe('ver1');
|
||||
});
|
||||
|
||||
it('moves to the version flagged active when the open one is deleted', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver1');
|
||||
|
||||
expect(versionIds(harness)).toEqual(['ver2', 'ver3']);
|
||||
expect(harness.result.current.activeVersionId).toBe('ver3');
|
||||
});
|
||||
|
||||
it('falls back to the first remaining version when none is flagged active', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver3');
|
||||
await deleteVersion(harness, 'ver1');
|
||||
|
||||
expect(versionIds(harness)).toEqual(['ver2']);
|
||||
expect(harness.result.current.activeVersionId).toBe('ver2');
|
||||
});
|
||||
|
||||
it('keeps the version when the server refuses to delete it', async () => {
|
||||
fetchMock.mockResolvedValue(fail(403, { error: 'Cannot delete the only version' }));
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver2');
|
||||
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
|
||||
expect(harness.result.current.actions.showDeleteVersionDialog).toBe(true);
|
||||
expect(toastError).toHaveBeenCalledWith('Cannot delete the only version');
|
||||
expect(toastSuccess).not.toHaveBeenCalled();
|
||||
expect(harness.result.current.actions.isDeletingVersion).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a generic message when the refusal body says nothing', async () => {
|
||||
fetchMock.mockResolvedValue(fail(500, {}));
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver2');
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to delete version');
|
||||
});
|
||||
|
||||
it('keeps the version when the request throws', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver2');
|
||||
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver2', 'ver3']);
|
||||
expect(toastError).toHaveBeenCalledWith('offline');
|
||||
});
|
||||
|
||||
// Confirming twice must not remove a second, unrelated version: the second
|
||||
// pass runs after versionToDelete has been cleared.
|
||||
it('is a no-op the second time the confirm button is pressed', async () => {
|
||||
const harness = renderVersionActions();
|
||||
|
||||
await deleteVersion(harness, 'ver2');
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleDeleteVersion();
|
||||
});
|
||||
|
||||
expect(callsTo(`${VERSIONS_URL}/ver2`, 'DELETE')).toHaveLength(1);
|
||||
expect(versionIds(harness)).toEqual(['ver1', 'ver3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,783 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
const toastError = vi.fn();
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => toastError(...args),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
type Params = Parameters<typeof useVideoAssets>[0];
|
||||
|
||||
const VIDEO_ID = 'vid1';
|
||||
/** The page size the hook hardcodes. */
|
||||
const PAGE_SIZE = 40;
|
||||
const FIRST_PAGE_URL = `/api/videos/${VIDEO_ID}/assets?limit=${PAGE_SIZE}&offset=0`;
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
|
||||
function makeAsset(overrides: Partial<VideoAsset> = {}): VideoAsset {
|
||||
return {
|
||||
id: 'a1',
|
||||
videoId: VIDEO_ID,
|
||||
kind: 'IMAGE',
|
||||
provider: 'R2_IMAGE',
|
||||
displayName: 'Reference frame',
|
||||
sourceUrl: '/api/upload/image/proj1/ref.png',
|
||||
providerVideoId: null,
|
||||
thumbnailUrl: null,
|
||||
uploadedByUserId: 'user1',
|
||||
uploadedByGuestName: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
uploadedByUser: { id: 'user1', name: 'Ada', image: null },
|
||||
canDelete: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface ListInit {
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
assets?: VideoAsset[];
|
||||
hasMore?: boolean;
|
||||
nextOffset?: number | null;
|
||||
etag?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function listResponse({
|
||||
ok = true,
|
||||
status = 200,
|
||||
assets = [],
|
||||
hasMore = false,
|
||||
nextOffset = null,
|
||||
etag = '"assets-1"',
|
||||
error,
|
||||
}: ListInit = {}) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
headers: { get: (name: string) => (name.toLowerCase() === 'etag' ? etag : null) },
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
ok
|
||||
? { data: { assets, pagination: { limit: PAGE_SIZE, offset: 0, hasMore, nextOffset } } }
|
||||
: { error }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(ok: boolean, payload: unknown, status = ok ? 200 : 400) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let clicked: string[];
|
||||
/** What the assets list endpoint answers with; reassign to change it mid-test. */
|
||||
let listed: ReturnType<typeof listResponse>;
|
||||
|
||||
function callsTo(url: string, method?: string) {
|
||||
return fetchMock.mock.calls.filter(
|
||||
(call) => call[0] === url && (call[1]?.method ?? undefined) === method
|
||||
);
|
||||
}
|
||||
|
||||
function headersOf(call: unknown[]): Record<string, string> {
|
||||
return ((call[1] as { headers?: Record<string, string> }).headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
}
|
||||
|
||||
function bodyOf(call: unknown[]): unknown {
|
||||
return JSON.parse((call[1] as { body: string }).body);
|
||||
}
|
||||
|
||||
type Harness = RenderHookResult<ReturnType<typeof useVideoAssets>, Params>;
|
||||
|
||||
async function renderAssets(overrides: Partial<Params> = {}): Promise<Harness> {
|
||||
const harness = renderHook((props: Params) => useVideoAssets(props), {
|
||||
initialProps: {
|
||||
videoId: VIDEO_ID,
|
||||
isAuthenticated: true,
|
||||
canUploadAssets: true,
|
||||
canDownloadAssets: true,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
// The mount-time read has to settle before any assertion.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
return harness;
|
||||
}
|
||||
|
||||
function assetIds(harness: Harness): string[] {
|
||||
return harness.result.current.assets.map((asset) => asset.id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clicked = [];
|
||||
listed = listResponse({ assets: [makeAsset()] });
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
if (typeof url === 'string' && url.startsWith(`/api/videos/${VIDEO_ID}/assets?`)) {
|
||||
return Promise.resolve(listed);
|
||||
}
|
||||
return Promise.resolve(jsonResponse(true, { data: makeAsset({ id: 'a-server' }) }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement
|
||||
) {
|
||||
clicked.push(this.getAttribute('href') ?? '');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
toastError.mockReset();
|
||||
});
|
||||
|
||||
describe('useVideoAssets reading the list', () => {
|
||||
it('reads the first page on mount, bypassing the cache', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
const call = callsTo(FIRST_PAGE_URL)[0];
|
||||
expect(call[1]).toMatchObject({ cache: 'no-store' });
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
expect(harness.result.current.isLoadingAssets).toBe(false);
|
||||
});
|
||||
|
||||
it('sends no conditional header before an etag is known', async () => {
|
||||
await renderAssets();
|
||||
|
||||
expect(headersOf(callsTo(FIRST_PAGE_URL)[0])).toEqual({});
|
||||
});
|
||||
|
||||
it('sends the stored etag back on the next conditional read', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchAssets({ useEtag: true });
|
||||
});
|
||||
|
||||
expect(headersOf(callsTo(FIRST_PAGE_URL)[1])).toEqual({ 'If-None-Match': '"assets-1"' });
|
||||
});
|
||||
|
||||
it('omits the etag when the caller wants a fresh read', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchAssets();
|
||||
});
|
||||
|
||||
expect(headersOf(callsTo(FIRST_PAGE_URL)[1])).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves the list on screen alone when the server answers 304', async () => {
|
||||
const harness = await renderAssets();
|
||||
listed = listResponse({ ok: false, status: 304, assets: [] });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchAssets({ useEtag: true });
|
||||
});
|
||||
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records how many more assets there are', async () => {
|
||||
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
|
||||
const harness = await renderAssets();
|
||||
|
||||
expect(harness.result.current.hasMoreAssets).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the message the server sent when the read is refused', async () => {
|
||||
listed = listResponse({ ok: false, status: 403, error: 'Access denied' });
|
||||
const harness = await renderAssets();
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Access denied');
|
||||
expect(assetIds(harness)).toEqual([]);
|
||||
expect(harness.result.current.isLoadingAssets).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a generic message when a 500 says nothing', async () => {
|
||||
listed = listResponse({ ok: false, status: 500 });
|
||||
await renderAssets();
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to fetch assets');
|
||||
});
|
||||
|
||||
it('keeps the list when a later read fails', async () => {
|
||||
const harness = await renderAssets();
|
||||
listed = listResponse({ ok: false, status: 500 });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchAssets();
|
||||
});
|
||||
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
});
|
||||
|
||||
it('says nothing at all on a silent read that fails', async () => {
|
||||
const harness = await renderAssets();
|
||||
listed = listResponse({ ok: false, status: 500, error: 'Access denied' });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchAssets({ silent: true });
|
||||
});
|
||||
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the visible spinner alone during a silent read', async () => {
|
||||
const pending = deferred<unknown>();
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockReturnValue(pending.promise);
|
||||
|
||||
let read: Promise<void> | undefined;
|
||||
act(() => {
|
||||
read = harness.result.current.fetchAssets({ silent: true });
|
||||
});
|
||||
expect(harness.result.current.isLoadingAssets).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve(listed);
|
||||
await read;
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a network failure', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = await renderAssets();
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to fetch assets');
|
||||
expect(harness.result.current.isLoadingAssets).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoAssets loading more', () => {
|
||||
it('does nothing when the first page was the whole list', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.loadMoreAssets();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks for the offset the server named and appends the page', async () => {
|
||||
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
|
||||
const harness = await renderAssets();
|
||||
listed = listResponse({ assets: [makeAsset({ id: 'a2' })], hasMore: false });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.loadMoreAssets();
|
||||
});
|
||||
|
||||
expect(callsTo(`/api/videos/${VIDEO_ID}/assets?limit=${PAGE_SIZE}&offset=40`)).toHaveLength(1);
|
||||
expect(assetIds(harness)).toEqual(['a1', 'a2']);
|
||||
expect(harness.result.current.hasMoreAssets).toBe(false);
|
||||
expect(harness.result.current.isLoadingMoreAssets).toBe(false);
|
||||
});
|
||||
|
||||
// The background poll can deliver a row the next page also contains.
|
||||
it('drops a row the visible page already holds', async () => {
|
||||
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
|
||||
const harness = await renderAssets();
|
||||
listed = listResponse({ assets: [makeAsset(), makeAsset({ id: 'a2' })] });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.loadMoreAssets();
|
||||
});
|
||||
|
||||
expect(assetIds(harness)).toEqual(['a1', 'a2']);
|
||||
});
|
||||
|
||||
it('shows the message the server sent when the next page fails', async () => {
|
||||
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
|
||||
const harness = await renderAssets();
|
||||
listed = listResponse({ ok: false, status: 500, error: 'Too many assets' });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.loadMoreAssets();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Too many assets');
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
expect(harness.result.current.isLoadingMoreAssets).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a network failure while paging', async () => {
|
||||
listed = listResponse({ assets: [makeAsset()], hasMore: true, nextOffset: 40 });
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.loadMoreAssets();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to load more assets');
|
||||
expect(harness.result.current.isLoadingMoreAssets).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoAssets background polling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function renderWithTimers(overrides: Partial<Params> = {}) {
|
||||
const harness = renderHook((props: Params) => useVideoAssets(props), {
|
||||
initialProps: {
|
||||
videoId: VIDEO_ID,
|
||||
isAuthenticated: true,
|
||||
canUploadAssets: true,
|
||||
canDownloadAssets: true,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
return harness;
|
||||
}
|
||||
|
||||
it('re-reads the list silently every 10 seconds', async () => {
|
||||
await renderWithTimers();
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1);
|
||||
});
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(2);
|
||||
expect(headersOf(callsTo(FIRST_PAGE_URL)[1])).toEqual({ 'If-None-Match': '"assets-1"' });
|
||||
});
|
||||
|
||||
it('skips the poll while the tab is hidden', async () => {
|
||||
await renderWithTimers();
|
||||
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
|
||||
});
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
|
||||
|
||||
visibility.mockReturnValue('visible');
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
|
||||
});
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips the poll while a write is still in flight', async () => {
|
||||
const harness = await renderWithTimers();
|
||||
const pending = deferred<unknown>();
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
url.startsWith(`/api/videos/${VIDEO_ID}/assets?`) ? Promise.resolve(listed) : pending.promise
|
||||
);
|
||||
|
||||
let created: Promise<unknown> | undefined;
|
||||
act(() => {
|
||||
created = harness.result.current.createAsset({
|
||||
provider: 'R2_IMAGE',
|
||||
sourceUrl: '/api/upload/image/proj1/new.png',
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
|
||||
});
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve(jsonResponse(true, { data: makeAsset({ id: 'a-server' }) }));
|
||||
await created;
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
|
||||
});
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('stops polling after unmount', async () => {
|
||||
const harness = await renderWithTimers();
|
||||
harness.unmount();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
|
||||
});
|
||||
expect(callsTo(FIRST_PAGE_URL)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoAssets creating', () => {
|
||||
const payload = {
|
||||
provider: 'R2_IMAGE' as const,
|
||||
displayName: 'New reference',
|
||||
sourceUrl: '/api/upload/image/proj1/new.png',
|
||||
};
|
||||
|
||||
it('refuses a viewer who cannot upload, without reaching the network', async () => {
|
||||
const harness = await renderAssets({ canUploadAssets: false });
|
||||
fetchMock.mockClear();
|
||||
|
||||
let created: VideoAsset | null | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
expect(created).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledWith('You do not have permission to upload assets');
|
||||
});
|
||||
|
||||
it('posts the payload to the assets route and prepends the saved row', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
let created: VideoAsset | null | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
const post = callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0];
|
||||
expect(bodyOf(post)).toEqual(payload);
|
||||
expect(created?.id).toBe('a-server');
|
||||
expect(assetIds(harness)).toEqual(['a-server', 'a1']);
|
||||
expect(harness.result.current.isCreatingAsset).toBe(false);
|
||||
});
|
||||
|
||||
it('signs a guest upload with the trimmed guest name', async () => {
|
||||
const harness = await renderAssets({ isAuthenticated: false, guestName: ' Kerem ' });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
expect(bodyOf(callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0])).toEqual({
|
||||
...payload,
|
||||
guestName: 'Kerem',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to "Guest" when the viewer never gave a name', async () => {
|
||||
const harness = await renderAssets({ isAuthenticated: false, guestName: ' ' });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
expect(bodyOf(callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0])).toMatchObject({
|
||||
guestName: 'Guest',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends no guest name for a signed-in uploader', async () => {
|
||||
const harness = await renderAssets({ isAuthenticated: true, guestName: 'Kerem' });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
expect(bodyOf(callsTo(`/api/videos/${VIDEO_ID}/assets`, 'POST')[0])).toEqual(payload);
|
||||
});
|
||||
|
||||
it('leaves the list untouched when the server refuses the upload', async () => {
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
url.startsWith(`/api/videos/${VIDEO_ID}/assets?`)
|
||||
? Promise.resolve(listed)
|
||||
: Promise.resolve(jsonResponse(false, { error: 'Asset limit reached' }, 403))
|
||||
);
|
||||
const harness = await renderAssets();
|
||||
|
||||
let created: VideoAsset | null | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
expect(created).toBeNull();
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
expect(toastError).toHaveBeenCalledWith('Asset limit reached');
|
||||
expect(harness.result.current.isCreatingAsset).toBe(false);
|
||||
});
|
||||
|
||||
// A 2xx with an empty body would otherwise push `undefined` into the list.
|
||||
it('treats a success with no row in it as a failure', async () => {
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
url.startsWith(`/api/videos/${VIDEO_ID}/assets?`)
|
||||
? Promise.resolve(listed)
|
||||
: Promise.resolve(jsonResponse(true, {}))
|
||||
);
|
||||
const harness = await renderAssets();
|
||||
|
||||
let created: VideoAsset | null | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
expect(created).toBeNull();
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to create asset');
|
||||
});
|
||||
|
||||
it('reports a network failure without hanging the busy flag', async () => {
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
|
||||
let created: VideoAsset | null | undefined;
|
||||
await act(async () => {
|
||||
created = await harness.result.current.createAsset(payload);
|
||||
});
|
||||
|
||||
expect(created).toBeNull();
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to create asset');
|
||||
expect(harness.result.current.isCreatingAsset).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoAssets deleting', () => {
|
||||
it('deletes through the asset route and drops the row', async () => {
|
||||
listed = listResponse({ assets: [makeAsset(), makeAsset({ id: 'a2' })] });
|
||||
const harness = await renderAssets();
|
||||
|
||||
let deleted: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleted = await harness.result.current.deleteAsset('a1');
|
||||
});
|
||||
|
||||
expect(callsTo(`/api/videos/${VIDEO_ID}/assets/a1`, 'DELETE')).toHaveLength(1);
|
||||
expect(deleted).toBe(true);
|
||||
expect(assetIds(harness)).toEqual(['a2']);
|
||||
expect(harness.result.current.activeDeleteAssetId).toBeNull();
|
||||
});
|
||||
|
||||
it('marks which row is being deleted while the request runs', async () => {
|
||||
const harness = await renderAssets();
|
||||
const pending = deferred<unknown>();
|
||||
fetchMock.mockReturnValue(pending.promise);
|
||||
|
||||
let removal: Promise<boolean> | undefined;
|
||||
act(() => {
|
||||
removal = harness.result.current.deleteAsset('a1');
|
||||
});
|
||||
expect(harness.result.current.activeDeleteAssetId).toBe('a1');
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve(jsonResponse(true, {}));
|
||||
await removal;
|
||||
});
|
||||
expect(harness.result.current.activeDeleteAssetId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the row when the server refuses the delete', async () => {
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(false, { error: 'Only the uploader can delete' }, 403)
|
||||
);
|
||||
|
||||
let deleted: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleted = await harness.result.current.deleteAsset('a1');
|
||||
});
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
expect(toastError).toHaveBeenCalledWith('Only the uploader can delete');
|
||||
expect(harness.result.current.activeDeleteAssetId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the row when the delete throws', async () => {
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
|
||||
let deleted: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleted = await harness.result.current.deleteAsset('a1');
|
||||
});
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(assetIds(harness)).toEqual(['a1']);
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to delete asset');
|
||||
});
|
||||
|
||||
it('removes both rows when two deletes are fired back to back', async () => {
|
||||
listed = listResponse({ assets: [makeAsset(), makeAsset({ id: 'a2' })] });
|
||||
const harness = await renderAssets();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.all([
|
||||
harness.result.current.deleteAsset('a1'),
|
||||
harness.result.current.deleteAsset('a2'),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(assetIds(harness)).toEqual([]);
|
||||
expect(harness.result.current.activeDeleteAssetId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoAssets downloading', () => {
|
||||
const downloadUrl = `/api/videos/${VIDEO_ID}/assets/a1/download`;
|
||||
|
||||
it('refuses a guest who cannot download', async () => {
|
||||
const harness = await renderAssets({ canDownloadAssets: false });
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.downloadAsset(makeAsset());
|
||||
});
|
||||
|
||||
expect(clicked).toEqual([]);
|
||||
expect(toastError).toHaveBeenCalledWith('Asset downloads require an authenticated account');
|
||||
});
|
||||
|
||||
it('refuses a YouTube asset, which has no file behind it', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.downloadAsset(makeAsset({ provider: 'YOUTUBE' }));
|
||||
});
|
||||
|
||||
expect(clicked).toEqual([]);
|
||||
expect(toastError).toHaveBeenCalledWith('YouTube assets cannot be downloaded');
|
||||
});
|
||||
|
||||
it('navigates straight to the download route for an R2 asset', async () => {
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.downloadAsset(makeAsset());
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(clicked).toEqual([downloadUrl]);
|
||||
expect(document.querySelectorAll('a')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('asks Bunny to prepare the file before navigating', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }), 'original');
|
||||
});
|
||||
|
||||
expect(callsTo(`${downloadUrl}?source=original&prepare=1`)[0][1]).toEqual({
|
||||
cache: 'no-store',
|
||||
});
|
||||
expect(clicked).toEqual([`${downloadUrl}?source=original`]);
|
||||
});
|
||||
|
||||
it('defaults a Bunny asset to the compressed file', async () => {
|
||||
const harness = await renderAssets();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }));
|
||||
});
|
||||
|
||||
expect(clicked).toEqual([`${downloadUrl}?source=compressed`]);
|
||||
});
|
||||
|
||||
it('shows the message Bunny sent and navigates nowhere when preparing fails', async () => {
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockResolvedValue(jsonResponse(false, { error: 'Still encoding' }, 409));
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }));
|
||||
});
|
||||
|
||||
expect(clicked).toEqual([]);
|
||||
expect(toastError).toHaveBeenCalledWith('Still encoding');
|
||||
expect(harness.result.current.activeDownloadAssetId).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a network failure while preparing', async () => {
|
||||
const harness = await renderAssets();
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.downloadAsset(makeAsset({ provider: 'BUNNY' }));
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Failed to start download');
|
||||
expect(harness.result.current.activeDownloadAssetId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoAssets guest upload tokens', () => {
|
||||
it('needs no token for a signed-in uploader', async () => {
|
||||
const harness = await renderAssets({ isAuthenticated: true });
|
||||
fetchMock.mockClear();
|
||||
|
||||
let token: string | null | undefined;
|
||||
await act(async () => {
|
||||
token = await harness.result.current.getGuestUploadToken('image');
|
||||
});
|
||||
|
||||
expect(token).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks the watch route for a token scoped to the intent', async () => {
|
||||
const harness = await renderAssets({ isAuthenticated: false });
|
||||
fetchMock.mockResolvedValue(jsonResponse(true, { data: { token: 'guest-token' } }));
|
||||
|
||||
let token: string | null | undefined;
|
||||
await act(async () => {
|
||||
token = await harness.result.current.getGuestUploadToken('audio');
|
||||
});
|
||||
|
||||
const post = callsTo(`/api/watch/${VIDEO_ID}/upload-token`, 'POST')[0];
|
||||
expect(bodyOf(post)).toEqual({ intent: 'audio' });
|
||||
expect(token).toBe('guest-token');
|
||||
});
|
||||
|
||||
it('throws the message the server sent when the grant is refused', async () => {
|
||||
const harness = await renderAssets({ isAuthenticated: false });
|
||||
fetchMock.mockResolvedValue(jsonResponse(false, { error: 'Guest uploads are disabled' }, 403));
|
||||
|
||||
await expect(harness.result.current.getGuestUploadToken('image')).rejects.toThrow(
|
||||
'Guest uploads are disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a 200 comes back with no token in it', async () => {
|
||||
const harness = await renderAssets({ isAuthenticated: false });
|
||||
fetchMock.mockResolvedValue(jsonResponse(true, { data: {} }));
|
||||
|
||||
await expect(harness.result.current.getGuestUploadToken('image')).rejects.toThrow(
|
||||
'Failed to prepare upload'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { useVideoPageData } from '@/components/video-page/hooks/use-video-page-data';
|
||||
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
|
||||
|
||||
type Params = Parameters<typeof useVideoPageData>[0];
|
||||
|
||||
const VIDEO_ID = 'vid1';
|
||||
const PROJECT_ID = 'proj1';
|
||||
const DASHBOARD_URL = `/api/projects/${PROJECT_ID}/videos/${VIDEO_ID}?includeComments=false`;
|
||||
const WATCH_URL = `/api/watch/${VIDEO_ID}`;
|
||||
const TAGS_URL = `/api/projects/${PROJECT_ID}/tags?videoId=${VIDEO_ID}`;
|
||||
/** The page size the hook hardcodes when walking the comment list. */
|
||||
const COMMENT_PAGE_SIZE = 200;
|
||||
|
||||
function commentsUrl(versionId: string, offset: number) {
|
||||
return `/api/versions/${versionId}/comments?includeResolved=true&limit=${COMMENT_PAGE_SIZE}&offset=${offset}`;
|
||||
}
|
||||
|
||||
function makeVersion(overrides: Partial<Version> = {}): Version {
|
||||
return {
|
||||
id: 'ver1',
|
||||
versionNumber: 1,
|
||||
versionLabel: null,
|
||||
providerId: 'bunny',
|
||||
videoId: VIDEO_ID,
|
||||
originalUrl: 'https://cdn.example.test/a.mp4',
|
||||
title: null,
|
||||
thumbnailUrl: null,
|
||||
duration: 600,
|
||||
isActive: true,
|
||||
_count: { comments: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeComment(overrides: Partial<Comment> = {}): Comment {
|
||||
return {
|
||||
id: 'c1',
|
||||
content: 'Colour is off',
|
||||
timestamp: 5,
|
||||
timestampEnd: null,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
annotationData: null,
|
||||
isResolved: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
author: { id: 'user1', name: 'Ada', image: null },
|
||||
guestName: null,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
tag: null,
|
||||
replies: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const TAGS: CommentTag[] = [
|
||||
{ id: 'tag-audio', name: 'Audio', color: '#f00' },
|
||||
{ id: 'tag-colour', name: 'Colour', color: '#0f0' },
|
||||
];
|
||||
|
||||
interface Responder {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
headers: { get: (name: string) => string | null };
|
||||
}
|
||||
|
||||
function respond({
|
||||
ok = true,
|
||||
status = 200,
|
||||
payload = {} as unknown,
|
||||
text = '',
|
||||
etag = null as string | null,
|
||||
}): Responder {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: () => Promise.resolve(payload),
|
||||
text: () => Promise.resolve(text),
|
||||
headers: { get: (name: string) => (name.toLowerCase() === 'etag' ? etag : null) },
|
||||
};
|
||||
}
|
||||
|
||||
/** The three endpoints the hook touches, each reassignable per test. */
|
||||
let videoResponse: Responder;
|
||||
let commentPages: Responder[];
|
||||
let tagsResponse: Responder;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function commentsPayload(comments: Comment[], hasMore = false) {
|
||||
return { data: { comments, hasMore } };
|
||||
}
|
||||
|
||||
function callsMatching(predicate: (url: string) => boolean) {
|
||||
return fetchMock.mock.calls.filter((call) => predicate(call[0] as string));
|
||||
}
|
||||
|
||||
function headersOf(call: unknown[]): Record<string, string> {
|
||||
return ((call[1] as { headers?: Record<string, string> }).headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
}
|
||||
|
||||
type Harness = RenderHookResult<ReturnType<typeof useVideoPageData>, Params>;
|
||||
|
||||
/** Mount, then let the video load, the comment load and the tag load chain. */
|
||||
async function renderPage(overrides: Partial<Params> = {}): Promise<Harness> {
|
||||
const harness = renderHook((props: Params) => useVideoPageData(props), {
|
||||
initialProps: {
|
||||
mode: 'dashboard',
|
||||
videoId: VIDEO_ID,
|
||||
propProjectId: PROJECT_ID,
|
||||
...overrides,
|
||||
} as Params,
|
||||
});
|
||||
await settle();
|
||||
return harness;
|
||||
}
|
||||
|
||||
async function settle(rounds = 6) {
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function activeComments(harness: Harness, versionId = 'ver1'): Comment[] {
|
||||
return harness.result.current.video?.versions.find((v) => v.id === versionId)?.comments ?? [];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
videoResponse = respond({
|
||||
payload: {
|
||||
data: {
|
||||
id: VIDEO_ID,
|
||||
title: 'Cut 3',
|
||||
description: null,
|
||||
projectId: PROJECT_ID,
|
||||
project: { name: 'Ad campaign', ownerId: 'user1' },
|
||||
isAuthenticated: true,
|
||||
currentUserId: 'user1',
|
||||
currentUserName: 'Ada',
|
||||
versions: [makeVersion(), makeVersion({ id: 'ver2', versionNumber: 2, isActive: false })],
|
||||
},
|
||||
},
|
||||
});
|
||||
commentPages = [respond({ payload: commentsPayload([makeComment()]), etag: 'W/"c-1"' })];
|
||||
tagsResponse = respond({ payload: { data: TAGS } });
|
||||
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
if (url === DASHBOARD_URL || url === WATCH_URL) return Promise.resolve(videoResponse);
|
||||
if (url.includes('/comments?')) {
|
||||
// Serve the page the offset asks for, so a test can reassign commentPages
|
||||
// and replay the same walk.
|
||||
const offset = Number(new URLSearchParams(url.split('?')[1]).get('offset') ?? 0);
|
||||
const index = Math.min(offset / COMMENT_PAGE_SIZE, commentPages.length - 1);
|
||||
return Promise.resolve(commentPages[index]);
|
||||
}
|
||||
if (url.includes('/tags')) return Promise.resolve(tagsResponse);
|
||||
return Promise.resolve(respond({ payload: { data: {} } }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useVideoPageData loading the video', () => {
|
||||
it('reads the project-scoped route without comments in dashboard mode', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(callsMatching((url) => url === DASHBOARD_URL)[0][1]).toEqual({ cache: 'no-store' });
|
||||
expect(harness.result.current.video?.title).toBe('Cut 3');
|
||||
expect(harness.result.current.loading).toBe(false);
|
||||
expect(harness.result.current.error).toBe('');
|
||||
});
|
||||
|
||||
it('reads the public watch route in watch mode', async () => {
|
||||
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
|
||||
|
||||
expect(callsMatching((url) => url === WATCH_URL)).toHaveLength(1);
|
||||
expect(callsMatching((url) => url === DASHBOARD_URL)).toHaveLength(0);
|
||||
expect(harness.result.current.video?.id).toBe(VIDEO_ID);
|
||||
});
|
||||
|
||||
it('gives every version a comments array even when the route omits one', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.video?.versions.map((v) => Array.isArray(v.comments))).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens the version the server flagged active', async () => {
|
||||
videoResponse = respond({
|
||||
payload: {
|
||||
data: {
|
||||
id: VIDEO_ID,
|
||||
projectId: PROJECT_ID,
|
||||
versions: [
|
||||
makeVersion({ id: 'ver1', isActive: false }),
|
||||
makeVersion({ id: 'ver2', isActive: true }),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.activeVersionId).toBe('ver2');
|
||||
});
|
||||
|
||||
it('falls back to the first version when none is flagged', async () => {
|
||||
videoResponse = respond({
|
||||
payload: {
|
||||
data: {
|
||||
id: VIDEO_ID,
|
||||
projectId: PROJECT_ID,
|
||||
versions: [
|
||||
makeVersion({ id: 'ver1', isActive: false }),
|
||||
makeVersion({ id: 'ver2', isActive: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.activeVersionId).toBe('ver1');
|
||||
});
|
||||
|
||||
it('opens nothing for a video with no versions yet', async () => {
|
||||
videoResponse = respond({
|
||||
payload: { data: { id: VIDEO_ID, projectId: PROJECT_ID, versions: [] } },
|
||||
});
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.activeVersionId).toBeNull();
|
||||
expect(callsMatching((url) => url.includes('/comments?'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the status and body of a dashboard failure, which an editor can act on', async () => {
|
||||
videoResponse = respond({ ok: false, status: 403, text: 'Forbidden' });
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.error).toBe('Failed to load video: 403 Forbidden');
|
||||
expect(harness.result.current.video).toBeNull();
|
||||
expect(harness.result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// A share-link viewer must not be told whether the video exists.
|
||||
it('says nothing specific about a watch failure', async () => {
|
||||
videoResponse = respond({ ok: false, status: 403, text: 'Forbidden' });
|
||||
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
|
||||
|
||||
expect(harness.result.current.error).toBe('Video not found or access denied');
|
||||
});
|
||||
|
||||
it('reports a network failure and stops loading', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.error).toBe('Failed to load video');
|
||||
expect(harness.result.current.loading).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Error fetching video:',
|
||||
expect.objectContaining({ message: 'offline' })
|
||||
);
|
||||
});
|
||||
|
||||
it('re-reads the video when the mode switches', async () => {
|
||||
const harness = await renderPage();
|
||||
expect(callsMatching((url) => url === DASHBOARD_URL)).toHaveLength(1);
|
||||
|
||||
harness.rerender({ mode: 'watch', videoId: VIDEO_ID, propProjectId: PROJECT_ID });
|
||||
await settle();
|
||||
|
||||
expect(callsMatching((url) => url === WATCH_URL)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoPageData loading comments', () => {
|
||||
it('reads the active version comments, resolved ones included', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(callsMatching((url) => url === commentsUrl('ver1', 0))).toHaveLength(1);
|
||||
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('walks every page until the server says there are no more', async () => {
|
||||
commentPages = [
|
||||
respond({ payload: commentsPayload([makeComment({ id: 'c1' })], true), etag: 'W/"c-1"' }),
|
||||
respond({ payload: commentsPayload([makeComment({ id: 'c2' })], true) }),
|
||||
respond({ payload: commentsPayload([makeComment({ id: 'c3' })], false) }),
|
||||
];
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(callsMatching((url) => url.includes('/comments?')).map((call) => call[0])).toEqual([
|
||||
commentsUrl('ver1', 0),
|
||||
commentsUrl('ver1', 200),
|
||||
commentsUrl('ver1', 400),
|
||||
]);
|
||||
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1', 'c2', 'c3']);
|
||||
});
|
||||
|
||||
it('counts replies towards the badge on the version', async () => {
|
||||
commentPages = [
|
||||
respond({
|
||||
payload: commentsPayload([
|
||||
makeComment({
|
||||
id: 'c1',
|
||||
replies: [
|
||||
{
|
||||
id: 'r1',
|
||||
content: 'Agreed',
|
||||
timestamp: 5,
|
||||
timestampEnd: null,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
annotationData: null,
|
||||
createdAt: '2026-01-01T00:01:00.000Z',
|
||||
author: { id: 'user2', name: 'Linus', image: null },
|
||||
guestName: null,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
tag: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeComment({ id: 'c2' }),
|
||||
]),
|
||||
}),
|
||||
];
|
||||
const harness = await renderPage();
|
||||
|
||||
const version = harness.result.current.video?.versions.find((v) => v.id === 'ver1');
|
||||
expect(version?._count).toEqual({ comments: 3 });
|
||||
});
|
||||
|
||||
it('touches only the version it was asked about', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
commentPages = [respond({ payload: commentsPayload([makeComment({ id: 'c-other' })]) })];
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchVersionComments('ver2', false);
|
||||
});
|
||||
|
||||
expect(activeComments(harness, 'ver1').map((c) => c.id)).toEqual(['c1']);
|
||||
expect(activeComments(harness, 'ver2').map((c) => c.id)).toEqual(['c-other']);
|
||||
});
|
||||
|
||||
it('sends no conditional header before an etag is known', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(headersOf(callsMatching((url) => url === commentsUrl('ver1', 0))[0])).toEqual({});
|
||||
});
|
||||
|
||||
it('sends the stored etag back on the next conditional read', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchVersionComments('ver1', true);
|
||||
});
|
||||
|
||||
const reads = callsMatching((url) => url === commentsUrl('ver1', 0));
|
||||
expect(headersOf(reads[1])).toEqual({ 'If-None-Match': 'W/"c-1"' });
|
||||
});
|
||||
|
||||
it('omits the etag when the caller wants the list unconditionally', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchVersionComments('ver1', false);
|
||||
});
|
||||
|
||||
const reads = callsMatching((url) => url === commentsUrl('ver1', 0));
|
||||
expect(headersOf(reads[1])).toEqual({});
|
||||
});
|
||||
|
||||
it('never sends a conditional header on a follow-up page', async () => {
|
||||
commentPages = [
|
||||
respond({ payload: commentsPayload([makeComment()], true), etag: 'W/"c-1"' }),
|
||||
respond({ payload: commentsPayload([makeComment({ id: 'c2' })], false) }),
|
||||
];
|
||||
const harness = await renderPage();
|
||||
|
||||
commentPages = [
|
||||
respond({ payload: commentsPayload([makeComment()], true), etag: 'W/"c-2"' }),
|
||||
respond({ payload: commentsPayload([makeComment({ id: 'c2' })], false) }),
|
||||
];
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchVersionComments('ver1', true);
|
||||
});
|
||||
|
||||
expect(headersOf(callsMatching((url) => url === commentsUrl('ver1', 200))[1])).toEqual({});
|
||||
});
|
||||
|
||||
// A real 304 and a real 403 carry no comment list. These fakes do, so that
|
||||
// the assertion proves the status is what stops the write rather than the
|
||||
// body happening to be empty.
|
||||
it('leaves the comments alone when the server answers 304', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
commentPages = [
|
||||
respond({ ok: false, status: 304, payload: commentsPayload([makeComment({ id: 'c-304' })]) }),
|
||||
];
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchVersionComments('ver1', true);
|
||||
});
|
||||
|
||||
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('leaves the comments alone when the read is refused', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
commentPages = [
|
||||
respond({ ok: false, status: 403, payload: commentsPayload([makeComment({ id: 'c-403' })]) }),
|
||||
];
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchVersionComments('ver1', false);
|
||||
});
|
||||
|
||||
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('leaves the comments alone when the body carries no list', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
commentPages = [respond({ payload: { data: {} } })];
|
||||
await act(async () => {
|
||||
await harness.result.current.fetchVersionComments('ver1', false);
|
||||
});
|
||||
|
||||
expect(activeComments(harness).map((c) => c.id)).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('re-reads comments when the caller switches version', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
act(() => harness.result.current.setActiveVersionId('ver2'));
|
||||
await settle();
|
||||
|
||||
expect(callsMatching((url) => url === commentsUrl('ver2', 0))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoPageData loading tags', () => {
|
||||
it('reads the project tags scoped to this video', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(callsMatching((url) => url === TAGS_URL).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preselects the first tag for the composer', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.availableTags).toEqual(TAGS);
|
||||
expect(harness.result.current.selectedTagId).toBe('tag-audio');
|
||||
});
|
||||
|
||||
it('does not override a tag the editor already picked', async () => {
|
||||
const harness = await renderPage();
|
||||
|
||||
act(() => harness.result.current.setSelectedTagId('tag-colour'));
|
||||
await settle();
|
||||
|
||||
expect(harness.result.current.selectedTagId).toBe('tag-colour');
|
||||
});
|
||||
|
||||
// KNOWN INEFFICIENCY, pinned rather than fixed. selectedTagId is in the
|
||||
// effect's dependency list purely so the auto-select can read it, so the
|
||||
// moment the first tag is selected the whole effect re-runs and the tag list
|
||||
// is fetched a second time on every page load.
|
||||
it('reads the tag list twice because selecting a tag re-runs the effect', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(callsMatching((url) => url === TAGS_URL)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('selects nothing when the project has no tags', async () => {
|
||||
tagsResponse = respond({ payload: { data: [] } });
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.availableTags).toEqual([]);
|
||||
expect(harness.result.current.selectedTagId).toBeNull();
|
||||
expect(callsMatching((url) => url === TAGS_URL)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('swallows a refused tag read rather than blocking the page', async () => {
|
||||
tagsResponse = respond({ ok: false, status: 403 });
|
||||
const harness = await renderPage();
|
||||
|
||||
expect(harness.result.current.availableTags).toEqual([]);
|
||||
expect(harness.result.current.error).toBe('');
|
||||
expect(harness.result.current.video?.title).toBe('Cut 3');
|
||||
});
|
||||
|
||||
it('takes the project from the loaded video in watch mode', async () => {
|
||||
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
|
||||
|
||||
expect(harness.result.current.projectId).toBe(PROJECT_ID);
|
||||
expect(callsMatching((url) => url === TAGS_URL).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('asks for no tags while the video is still unknown', async () => {
|
||||
videoResponse = respond({ ok: false, status: 404, text: 'Not found' });
|
||||
const harness = await renderPage({ mode: 'watch', propProjectId: undefined });
|
||||
|
||||
expect(harness.result.current.projectId).toBeUndefined();
|
||||
expect(callsMatching((url) => url.includes('/tags'))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,583 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useVideoPlayer } from '@/components/video-page/hooks/use-video-player';
|
||||
import type { PlayerAdapter, Version } from '@/components/video-page/types';
|
||||
|
||||
type Params = Parameters<typeof useVideoPlayer>[0];
|
||||
|
||||
/** Measured from the video element's metadata, so every seek clamps to it. */
|
||||
const DURATION = 60;
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2];
|
||||
/** The timeline the tests drag over: 100px wide, starting at the viewport edge. */
|
||||
const TIMELINE_LEFT = 0;
|
||||
const TIMELINE_WIDTH = 100;
|
||||
/** The hook builds the player inside a 100ms timeout. */
|
||||
const PLAYER_INIT_DELAY_MS = 100;
|
||||
|
||||
type FrameMetadata = { mediaTime: number; presentedFrames: number };
|
||||
type FrameCallback = (now: number, metadata: FrameMetadata) => void;
|
||||
|
||||
/**
|
||||
* A stand-in for the HTMLVideoElement the R2 branch of the hook drives. jsdom
|
||||
* has no media pipeline at all: it never fires 'play' or 'loadedmetadata', and
|
||||
* `duration` is a read-only NaN. This object exposes only the surface the hook
|
||||
* touches, and lets a test fire the media events itself so the timing is
|
||||
* explicit rather than accidental.
|
||||
*/
|
||||
function createVideoStub() {
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
let frameCallback: FrameCallback | null = null;
|
||||
let nextFrameCallbackId = 1;
|
||||
|
||||
const video = {
|
||||
currentTime: 0,
|
||||
duration: DURATION,
|
||||
paused: true,
|
||||
muted: false,
|
||||
playbackRate: 1,
|
||||
seeking: false,
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
readyState: 2,
|
||||
src: '',
|
||||
play: vi.fn(() => {
|
||||
video.paused = false;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
video.paused = true;
|
||||
}),
|
||||
load: vi.fn(),
|
||||
removeAttribute: vi.fn(),
|
||||
addEventListener: (type: string, handler: () => void) => {
|
||||
const forType = listeners.get(type) ?? new Set<() => void>();
|
||||
forType.add(handler);
|
||||
listeners.set(type, forType);
|
||||
},
|
||||
removeEventListener: (type: string, handler: () => void) => {
|
||||
listeners.get(type)?.delete(handler);
|
||||
},
|
||||
requestVideoFrameCallback: vi.fn((callback: FrameCallback) => {
|
||||
frameCallback = callback;
|
||||
return nextFrameCallbackId++;
|
||||
}),
|
||||
cancelVideoFrameCallback: vi.fn(() => {
|
||||
frameCallback = null;
|
||||
}),
|
||||
/** Deliver a media event to whatever the hook has subscribed. */
|
||||
fire: (type: string) => {
|
||||
for (const handler of [...(listeners.get(type) ?? [])]) handler();
|
||||
},
|
||||
/** Deliver one presented-frame sample to the frame-rate tracker. */
|
||||
emitFrame: (metadata: FrameMetadata) => {
|
||||
const callback = frameCallback;
|
||||
frameCallback = null;
|
||||
callback?.(0, metadata);
|
||||
},
|
||||
};
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
type VideoStub = ReturnType<typeof createVideoStub>;
|
||||
|
||||
function makeVersion(): Version {
|
||||
return {
|
||||
id: 'ver1',
|
||||
versionNumber: 1,
|
||||
versionLabel: null,
|
||||
providerId: 'r2',
|
||||
videoId: 'vid1',
|
||||
originalUrl: '/api/upload/video/abc.mp4',
|
||||
title: null,
|
||||
thumbnailUrl: null,
|
||||
// Left unset so the duration under test is the one measured from the
|
||||
// element, which is what a real page ends up using.
|
||||
duration: null,
|
||||
isActive: true,
|
||||
_count: { comments: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeTimeline(): HTMLDivElement {
|
||||
const timeline = document.createElement('div');
|
||||
// jsdom does no layout, so every rect is zero unless we supply one.
|
||||
timeline.getBoundingClientRect = () =>
|
||||
({ left: TIMELINE_LEFT, width: TIMELINE_WIDTH }) as DOMRect;
|
||||
document.body.appendChild(timeline);
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function renderPlayer() {
|
||||
const video = createVideoStub();
|
||||
const timeline = makeTimeline();
|
||||
const readout = document.createElement('div');
|
||||
const playerRef: { current: PlayerAdapter | null } = { current: null };
|
||||
|
||||
const params: Params = {
|
||||
activeVersion: makeVersion(),
|
||||
activeVersionId: 'ver1',
|
||||
activeProviderId: 'r2',
|
||||
embedUrl: '/api/upload/video/abc.mp4',
|
||||
canInitializePlayer: true,
|
||||
iframeRef: { current: null },
|
||||
videoRef: { current: video as unknown as HTMLVideoElement },
|
||||
bunnyViewportRef: { current: null },
|
||||
timelineRef: { current: timeline },
|
||||
progressRef: { current: document.createElement('div') },
|
||||
playheadRef: { current: document.createElement('div') },
|
||||
scrubReadoutRef: { current: readout },
|
||||
hlsRef: { current: null },
|
||||
playerRef,
|
||||
formatTime: (seconds: number) => `${Math.floor(seconds)}s`,
|
||||
formatBunnyQualityLabel: () => 'auto',
|
||||
speedOptions: SPEED_OPTIONS,
|
||||
scheduleWatchProgressSaveRef: { current: vi.fn() },
|
||||
setViewingAnnotation: vi.fn(),
|
||||
};
|
||||
|
||||
const rendered = renderHook(() => useVideoPlayer(params));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(PLAYER_INIT_DELAY_MS);
|
||||
});
|
||||
// Without metadata the hook has no duration, so nothing would clamp.
|
||||
act(() => {
|
||||
video.fire('loadedmetadata');
|
||||
});
|
||||
|
||||
return { ...rendered, video, timeline, readout };
|
||||
}
|
||||
|
||||
/** Put the player into the playing state the way the media element would. */
|
||||
function startPlayback(video: VideoStub) {
|
||||
act(() => {
|
||||
video.paused = false;
|
||||
video.fire('play');
|
||||
});
|
||||
}
|
||||
|
||||
function stopPlayback(video: VideoStub) {
|
||||
act(() => {
|
||||
video.paused = true;
|
||||
video.fire('pause');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Two presented-frame samples one second apart is what the hook needs to derive
|
||||
* a rate; the first sample only establishes a baseline.
|
||||
*/
|
||||
function measureFrameRate(video: VideoStub, fps: number) {
|
||||
act(() => {
|
||||
video.emitFrame({ mediaTime: 0, presentedFrames: 0 });
|
||||
});
|
||||
act(() => {
|
||||
video.emitFrame({ mediaTime: 1, presentedFrames: fps });
|
||||
});
|
||||
}
|
||||
|
||||
function pressKey(
|
||||
code: string,
|
||||
options: { shiftKey?: boolean; target?: EventTarget } = {}
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
code,
|
||||
shiftKey: options.shiftKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
act(() => {
|
||||
(options.target ?? window).dispatchEvent(event);
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
function mouseEventAt(clientX: number) {
|
||||
return { clientX } as React.MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// The hook injects the YouTube iframe API before the first <script> on the
|
||||
// page. Next always renders one; jsdom renders none, and the hook would
|
||||
// dereference undefined.
|
||||
document.head.appendChild(document.createElement('script'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.onYouTubeIframeAPIReady = undefined;
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('useVideoPlayer seeking', () => {
|
||||
it('takes its duration from the loaded metadata', () => {
|
||||
const { result } = renderPlayer();
|
||||
|
||||
expect(result.current.isReady).toBe(true);
|
||||
expect(result.current.videoDuration).toBe(DURATION);
|
||||
});
|
||||
|
||||
it('clamps a backwards skip at the start of the video', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(3));
|
||||
act(() => result.current.handleSkip(-5));
|
||||
|
||||
expect(result.current.currentTime).toBe(0);
|
||||
expect(video.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps a forwards skip at the end of the video', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(58));
|
||||
act(() => result.current.handleSkip(5));
|
||||
|
||||
expect(result.current.currentTime).toBe(DURATION);
|
||||
expect(video.currentTime).toBe(DURATION);
|
||||
});
|
||||
|
||||
it('seeks by the requested amount away from the ends', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(20));
|
||||
act(() => result.current.handleSkip(5));
|
||||
expect(result.current.currentTime).toBe(25);
|
||||
|
||||
act(() => result.current.handleSkip(-5));
|
||||
expect(result.current.currentTime).toBe(20);
|
||||
expect(video.currentTime).toBe(20);
|
||||
});
|
||||
|
||||
it('leaves a paused video paused after a seek, and a playing one playing', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleSkip(5));
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
|
||||
startPlayback(video);
|
||||
act(() => result.current.handleSkip(5));
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoPlayer frame stepping', () => {
|
||||
it('derives the frame rate from presented-frame samples', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
startPlayback(video);
|
||||
|
||||
expect(result.current.frameStepLabel).toBe('1s');
|
||||
|
||||
measureFrameRate(video, 25);
|
||||
|
||||
expect(result.current.frameStepSeconds).toBe(0.04);
|
||||
expect(result.current.frameStepLabel).toBe('1f');
|
||||
});
|
||||
|
||||
it('ignores samples taken across a seek', () => {
|
||||
// Frames presented either side of a seek come from two different points in
|
||||
// the timeline, so their ratio is not a frame rate.
|
||||
const { result, video } = renderPlayer();
|
||||
startPlayback(video);
|
||||
video.seeking = true;
|
||||
|
||||
measureFrameRate(video, 25);
|
||||
|
||||
expect(result.current.frameStepLabel).toBe('1s');
|
||||
});
|
||||
|
||||
it('moves exactly one frame per step once a rate is known', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
startPlayback(video);
|
||||
measureFrameRate(video, 25);
|
||||
stopPlayback(video);
|
||||
|
||||
act(() => result.current.handleFrameModeToggle());
|
||||
act(() => result.current.handleSeekToTimestamp(10));
|
||||
|
||||
// A 5-second skip request collapses to a single 1/25s frame.
|
||||
act(() => result.current.handleSkip(5));
|
||||
expect(result.current.currentTime).toBeCloseTo(10.04, 10);
|
||||
expect(video.currentTime).toBeCloseTo(10.04, 10);
|
||||
|
||||
act(() => result.current.handleSkip(5));
|
||||
expect(result.current.currentTime).toBeCloseTo(10.08, 10);
|
||||
|
||||
act(() => result.current.handleSkip(-5));
|
||||
expect(result.current.currentTime).toBeCloseTo(10.04, 10);
|
||||
});
|
||||
|
||||
it('steps a whole second while no frame rate has been measured', () => {
|
||||
const { result } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleFrameModeToggle());
|
||||
act(() => result.current.handleSeekToTimestamp(10));
|
||||
act(() => result.current.handleSkip(5));
|
||||
|
||||
expect(result.current.frameStepLabel).toBe('1s');
|
||||
expect(result.current.currentTime).toBe(11);
|
||||
});
|
||||
|
||||
it('skips the full requested amount while frame mode is off', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
startPlayback(video);
|
||||
measureFrameRate(video, 25);
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(10));
|
||||
act(() => result.current.handleSkip(5));
|
||||
|
||||
expect(result.current.isFrameMode).toBe(false);
|
||||
expect(result.current.currentTime).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoPlayer scrubbing', () => {
|
||||
it('seeks to the fraction of the duration the pointer landed on', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(TIMELINE_WIDTH / 2)));
|
||||
|
||||
expect(result.current.isDragging).toBe(true);
|
||||
expect(result.current.currentTime).toBe(DURATION / 2);
|
||||
// The drag previews live, so the element is seeked before release.
|
||||
expect(video.currentTime).toBe(DURATION / 2);
|
||||
});
|
||||
|
||||
it('clamps a drag dragged off either end of the timeline', () => {
|
||||
const { result } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(-500)));
|
||||
expect(result.current.currentTime).toBe(0);
|
||||
|
||||
act(() => result.current.handleTimelineMouseMove(mouseEventAt(5000)));
|
||||
expect(result.current.currentTime).toBe(DURATION);
|
||||
});
|
||||
|
||||
it('tracks the pointer even when it leaves the timeline', () => {
|
||||
const { result } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(10)));
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 75 }));
|
||||
});
|
||||
|
||||
expect(result.current.currentTime).toBe(45);
|
||||
});
|
||||
|
||||
it('commits the final position to the video element on release', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(10)));
|
||||
act(() => result.current.handleTimelineMouseMove(mouseEventAt(90)));
|
||||
act(() => result.current.handleTimelineMouseUp());
|
||||
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
expect(result.current.currentTime).toBe(54);
|
||||
expect(video.currentTime).toBe(54);
|
||||
});
|
||||
|
||||
it('freezes playback for the length of the drag and resumes it after', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
startPlayback(video);
|
||||
video.play.mockClear();
|
||||
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.handleTimelineMouseUp());
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves a paused video paused after a drag', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
|
||||
act(() => result.current.handleTimelineMouseUp());
|
||||
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the frame number under the cursor while dragging', () => {
|
||||
const { result, video, readout } = renderPlayer();
|
||||
startPlayback(video);
|
||||
measureFrameRate(video, 25);
|
||||
stopPlayback(video);
|
||||
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(TIMELINE_WIDTH / 2)));
|
||||
|
||||
// Halfway through a 60s clip at 25fps is second 30, frame 750.
|
||||
expect(readout.textContent).toBe('30s · f750');
|
||||
expect(result.current.showScrubReadout).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoPlayer keyboard shortcuts', () => {
|
||||
it('starts and stops playback on space', () => {
|
||||
const { video } = renderPlayer();
|
||||
|
||||
const first = pressKey('Space');
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
// Otherwise the page scrolls under the player.
|
||||
expect(first.defaultPrevented).toBe(true);
|
||||
|
||||
startPlayback(video);
|
||||
pressKey('Space');
|
||||
expect(video.pause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats K the same as space', () => {
|
||||
const { video } = renderPlayer();
|
||||
|
||||
pressKey('KeyK');
|
||||
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips five seconds with the left and right arrows', () => {
|
||||
const { result } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(20));
|
||||
|
||||
pressKey('ArrowRight');
|
||||
expect(result.current.currentTime).toBe(25);
|
||||
|
||||
pressKey('ArrowLeft');
|
||||
expect(result.current.currentTime).toBe(20);
|
||||
});
|
||||
|
||||
it('jumps ten seconds with J and L, clamped to the media', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(20));
|
||||
|
||||
pressKey('KeyL');
|
||||
expect(result.current.currentTime).toBe(30);
|
||||
expect(video.currentTime).toBe(30);
|
||||
|
||||
pressKey('KeyJ');
|
||||
expect(result.current.currentTime).toBe(20);
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(5));
|
||||
pressKey('KeyJ');
|
||||
expect(result.current.currentTime).toBe(0);
|
||||
|
||||
act(() => result.current.handleSeekToTimestamp(55));
|
||||
pressKey('KeyL');
|
||||
expect(result.current.currentTime).toBe(DURATION);
|
||||
});
|
||||
|
||||
it('toggles mute on the element with M', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
pressKey('KeyM');
|
||||
expect(video.muted).toBe(true);
|
||||
expect(result.current.isMuted).toBe(true);
|
||||
|
||||
pressKey('KeyM');
|
||||
expect(video.muted).toBe(false);
|
||||
expect(result.current.isMuted).toBe(false);
|
||||
});
|
||||
|
||||
it('steps the speed ladder with the up and down arrows, stopping at the ends', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
|
||||
pressKey('ArrowUp');
|
||||
expect(result.current.playbackSpeed).toBe(1.5);
|
||||
expect(video.playbackRate).toBe(1.5);
|
||||
|
||||
pressKey('ArrowUp');
|
||||
expect(result.current.playbackSpeed).toBe(2);
|
||||
|
||||
// 2x is the top of the ladder: the shortcut must not wrap around.
|
||||
pressKey('ArrowUp');
|
||||
expect(result.current.playbackSpeed).toBe(2);
|
||||
|
||||
pressKey('ArrowDown');
|
||||
expect(result.current.playbackSpeed).toBe(1.5);
|
||||
expect(video.playbackRate).toBe(1.5);
|
||||
});
|
||||
|
||||
it('steps the speed ladder with shifted comma and period', () => {
|
||||
const { result } = renderPlayer();
|
||||
|
||||
pressKey('Period', { shiftKey: true });
|
||||
expect(result.current.playbackSpeed).toBe(1.5);
|
||||
|
||||
pressKey('Comma', { shiftKey: true });
|
||||
expect(result.current.playbackSpeed).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves an unshifted comma alone so it can still be typed', () => {
|
||||
const { result } = renderPlayer();
|
||||
|
||||
const event = pressKey('Comma');
|
||||
|
||||
expect(result.current.playbackSpeed).toBe(1);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('requests fullscreen with F', async () => {
|
||||
const { result } = renderPlayer();
|
||||
const requestFullscreen = vi.fn().mockResolvedValue(undefined);
|
||||
document.documentElement.requestFullscreen = requestFullscreen;
|
||||
|
||||
pressKey('KeyF');
|
||||
await act(async () => {});
|
||||
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isFullscreenMode).toBe(true);
|
||||
// Fullscreen is for watching, so the comments pane gets out of the way.
|
||||
expect(result.current.showComments).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a shortcut typed into a text field', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
act(() => result.current.handleSeekToTimestamp(20));
|
||||
|
||||
const space = pressKey('Space', { target: input });
|
||||
pressKey('ArrowRight', { target: input });
|
||||
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
expect(result.current.currentTime).toBe(20);
|
||||
// Nothing was claimed, so the keystroke still reaches the field.
|
||||
expect(space.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a shortcut typed into a rich text editor', () => {
|
||||
const { video } = renderPlayer();
|
||||
const editor = document.createElement('div');
|
||||
editor.contentEditable = 'true';
|
||||
// jsdom does not derive isContentEditable from the attribute.
|
||||
Object.defineProperty(editor, 'isContentEditable', { value: true });
|
||||
document.body.appendChild(editor);
|
||||
|
||||
pressKey('Space', { target: editor });
|
||||
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores every shortcut while a dialog is open', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
const dialog = document.createElement('div');
|
||||
dialog.setAttribute('data-slot', 'dialog-content');
|
||||
document.body.appendChild(dialog);
|
||||
act(() => result.current.handleSeekToTimestamp(20));
|
||||
|
||||
pressKey('Space');
|
||||
pressKey('ArrowRight');
|
||||
pressKey('KeyM');
|
||||
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
expect(result.current.currentTime).toBe(20);
|
||||
expect(video.muted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// The /admin area, which nothing covered before.
|
||||
//
|
||||
// Admin is not a database column. lib/auth.ts:144 derives `token.isAdmin` on
|
||||
// every request by looking the signed-in address up in the ADMIN_EMAILS
|
||||
// environment variable of the *app under test*. That has two consequences for
|
||||
// this file:
|
||||
//
|
||||
// 1. The privileged account has to use a fixed address rather than the
|
||||
// per-test unique one every other spec gets, hence ADMIN_EMAIL below.
|
||||
// Because that address is unique in the database, the two tests that use it
|
||||
// must not run at the same time, hence the serial describe.
|
||||
// 2. That variable therefore has to be set for the app under test, and
|
||||
// playwright.config.ts sets it in APP_ENV to exactly ADMIN_EMAIL below.
|
||||
// Remove it and the privileged half of this spec has nothing to sign in as,
|
||||
// so the probe below **fails** rather than skipping: this is the only
|
||||
// positive coverage of the admin area in the repo outside
|
||||
// tests/api/auth-matrix.test.ts, and a config change should not be able to
|
||||
// quietly delete it.
|
||||
//
|
||||
// The probe reads /api/auth/session, which is a question about configuration,
|
||||
// not about the authorization being tested: if the /admin guard itself
|
||||
// regressed, isAdmin would still be true and the tests below would fail on
|
||||
// their own assertions.
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { anonTest, expect, E2E_PASSWORD, signInPage } from './fixtures';
|
||||
import { createUser } from '../factories';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* The address that must appear in the app's ADMIN_EMAILS for the privileged
|
||||
* tests to run. Lower case, because lib/auth.ts lower-cases both sides.
|
||||
*/
|
||||
const ADMIN_EMAIL = '[email protected]';
|
||||
|
||||
const ADMIN_SETUP_HINT =
|
||||
`The app under test does not treat ${ADMIN_EMAIL} as an admin. ` +
|
||||
`APP_ENV in playwright.config.ts must set ADMIN_EMAILS to '${ADMIN_EMAIL}'; ` +
|
||||
`this is a failure rather than a skip because it is the only place the admin ` +
|
||||
`area is exercised from a browser.`;
|
||||
|
||||
/** Whether the app considers the signed-in account an admin. */
|
||||
async function sessionIsAdmin(request: APIRequestContext): Promise<boolean> {
|
||||
const response = await request.get('/api/auth/session');
|
||||
if (!response.ok()) return false;
|
||||
const session = (await response.json()) as { user?: { isAdmin?: boolean } };
|
||||
return session.user?.isAdmin === true;
|
||||
}
|
||||
|
||||
/** Creates the fixed-address admin account and signs `page` in as it. */
|
||||
async function signInAsAdmin(page: Page): Promise<void> {
|
||||
await db.user.deleteMany({ where: { email: ADMIN_EMAIL } });
|
||||
await createUser({ name: 'E2E Admin', email: ADMIN_EMAIL, password: E2E_PASSWORD });
|
||||
await signInPage(page, ADMIN_EMAIL);
|
||||
}
|
||||
|
||||
anonTest.describe('the admin area', () => {
|
||||
// ADMIN_EMAIL is a unique column, so only one test may hold it at a time.
|
||||
anonTest.describe.configure({ mode: 'serial' });
|
||||
|
||||
anonTest.afterEach(async () => {
|
||||
await db.user.deleteMany({ where: { email: ADMIN_EMAIL } });
|
||||
});
|
||||
|
||||
anonTest('an ordinary signed-in user is refused every admin page', async ({ page, seed }) => {
|
||||
const user = await seed.user({ name: 'Not An Admin' });
|
||||
await signInPage(page, user.email ?? '');
|
||||
|
||||
// The session is real: without this the redirects below would prove nothing
|
||||
// more than that /admin is behind a login.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
expect(await sessionIsAdmin(page.context().request)).toEqual(false);
|
||||
|
||||
for (const route of ['/admin', '/admin/users', '/admin/feedback']) {
|
||||
await page.goto(route);
|
||||
// app/admin/layout.tsx sends a non-admin to the marketing root.
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard Overview' })).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Refused, not missing. A route that did not exist would answer 404, and a
|
||||
// 404 would satisfy every assertion above for the wrong reason.
|
||||
const missing = await page.request.get('/definitely-not-a-route', { maxRedirects: 0 });
|
||||
expect(missing.status()).toEqual(404);
|
||||
const admin = await page.request.get('/admin', { maxRedirects: 0 });
|
||||
expect(admin.status()).not.toEqual(404);
|
||||
|
||||
// The write side is guarded independently of the pages.
|
||||
const refresh = await page.request.post('/api/admin/stats/refresh-r2');
|
||||
expect(refresh.status()).toEqual(403);
|
||||
});
|
||||
|
||||
anonTest(
|
||||
'an admin reaches the dashboard and can search the user list',
|
||||
async ({ page, seed }) => {
|
||||
// Seeded before the probe so the search below has something to find.
|
||||
const target = await seed.user({ name: 'Findable Person' });
|
||||
const targetEmail = target.email ?? '';
|
||||
|
||||
await signInAsAdmin(page);
|
||||
expect(await sessionIsAdmin(page.context().request), ADMIN_SETUP_HINT).toBe(true);
|
||||
|
||||
await page.goto('/admin');
|
||||
await expect(page).toHaveURL(/\/admin$/);
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard Overview' })).toBeVisible();
|
||||
|
||||
// The dashboard is not a static shell: it counts rows, and the count has to
|
||||
// be at least the two accounts this test created.
|
||||
const totalUsers = Number(
|
||||
(await page.getByText('Total Users').locator('xpath=../..').innerText())
|
||||
.replace(/[^0-9]/g, '')
|
||||
.trim()
|
||||
);
|
||||
expect(totalUsers).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// --- one action: search the user list -----------------------------------
|
||||
await page.getByRole('link', { name: 'Users' }).first().click();
|
||||
await expect(page).toHaveURL(/\/admin\/users$/);
|
||||
|
||||
// Scoped to the form: the global header carries an icon button whose
|
||||
// accessible name is also "Search".
|
||||
const searchForm = page.locator('form[action="/admin/users"]');
|
||||
const search = searchForm.getByLabel('Search users by name or email');
|
||||
const submit = searchForm.getByRole('button', { name: 'Search' });
|
||||
|
||||
await search.fill(targetEmail);
|
||||
await submit.click();
|
||||
|
||||
await expect(page).toHaveURL(/[?&]q=/);
|
||||
await expect(page.getByText(targetEmail, { exact: true })).toBeVisible();
|
||||
|
||||
// The filter really filters: the admin's own row is in the unfiltered list
|
||||
// and must be absent from this one.
|
||||
await expect(page.getByText(ADMIN_EMAIL, { exact: true })).toHaveCount(0);
|
||||
|
||||
// And a query that matches nobody says so, rather than falling back to
|
||||
// everybody.
|
||||
await search.fill(`no-such-person-${Date.now()}@example.invalid`);
|
||||
await submit.click();
|
||||
await expect(page.getByText('No users match these filters.')).toBeVisible();
|
||||
await expect(page.getByText(targetEmail, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// Multi-select on the project page: deleting several videos at once, and moving
|
||||
// several videos into another project in the same workspace.
|
||||
//
|
||||
// Both flows are behind a selection mode that is only reachable through a card's
|
||||
// overflow menu, so the entry point is exercised here too. The assertions are
|
||||
// deliberately about rows that disappear from one page and appear on another,
|
||||
// not about the toast: a toast can be rendered by a handler that then does
|
||||
// nothing.
|
||||
import { test, expect } from './fixtures';
|
||||
import { createProject, createVideo, createVersion } from '../factories';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The overflow ("more") button on the card for `title`.
|
||||
*
|
||||
* That button is icon-only and has no accessible name, so it cannot be reached
|
||||
* by role and name. The <h3> title can, and the button is a sibling of the link
|
||||
* that wraps it: h3 -> link -> the flex row that holds both. See
|
||||
* components/video-card.tsx.
|
||||
*/
|
||||
function cardMenuFor(page: Page, title: string) {
|
||||
return page
|
||||
.getByRole('heading', { name: title, level: 3 })
|
||||
.locator('xpath=../..')
|
||||
.getByRole('button');
|
||||
}
|
||||
|
||||
/** Puts the page into selection mode through the first card's overflow menu. */
|
||||
async function enterSelectionMode(page: Page, anyTitle: string): Promise<void> {
|
||||
await cardMenuFor(page, anyTitle).click();
|
||||
await page.getByRole('menuitem', { name: 'Select' }).click();
|
||||
await expect(page.getByText('Selection mode')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Three videos in one project, each with an active version.
|
||||
*
|
||||
* The provider is `youtube`, which needs no object storage: nothing here plays
|
||||
* a video, it only lists and deletes them.
|
||||
*/
|
||||
async function seedVideos(projectId: string, titles: string[]): Promise<void> {
|
||||
for (const title of titles) {
|
||||
const video = await createVideo({ projectId, title });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'youtube',
|
||||
providerVideoId: 'dQw4w9WgXcQ',
|
||||
title,
|
||||
duration: 120,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('two of three videos are selected and deleted, and the third survives', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const doomedA = `Bulk Doomed A ${stamp}`;
|
||||
const doomedB = `Bulk Doomed B ${stamp}`;
|
||||
const survivor = `Bulk Survivor ${stamp}`;
|
||||
|
||||
const { project } = await seed.project(seededUser);
|
||||
await seedVideos(project.id, [doomedA, doomedB, survivor]);
|
||||
|
||||
await page.goto(`/projects/${project.id}`);
|
||||
for (const title of [doomedA, doomedB, survivor]) {
|
||||
await expect(page.getByRole('heading', { name: title, level: 3 })).toBeVisible();
|
||||
}
|
||||
|
||||
await enterSelectionMode(page, doomedA);
|
||||
|
||||
// Nothing is selected by the act of entering the mode, so the destructive
|
||||
// button starts disabled. That is the control for the click below.
|
||||
const deleteSelected = page.getByRole('button', { name: 'Delete selected' });
|
||||
await expect(deleteSelected).toBeDisabled();
|
||||
|
||||
await page.getByRole('checkbox', { name: `Select ${doomedA}` }).click();
|
||||
await page.getByRole('checkbox', { name: `Select ${doomedB}` }).click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
await expect(deleteSelected).toBeEnabled();
|
||||
|
||||
await deleteSelected.click();
|
||||
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Delete 2 videos?' })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Delete selected' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible();
|
||||
|
||||
// Gone from the database, not just from the client-side list that
|
||||
// handleDeleteSelected filters. A reload re-renders from the server.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('selected videos are moved into another project in the same workspace', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const moving = `Bulk Moving ${stamp}`;
|
||||
const staying = `Bulk Staying ${stamp}`;
|
||||
|
||||
// Both projects must share a workspace: the move dialog offers only
|
||||
// destinations inside it (app/api/projects/[projectId]/videos/move GET).
|
||||
const { project: source, workspaceId } = await seed.project(seededUser);
|
||||
const destination = await createProject({
|
||||
ownerId: seededUser.id,
|
||||
workspaceId,
|
||||
name: `Bulk Destination ${stamp}`,
|
||||
slug: `e2e-bulk-destination-${stamp}`,
|
||||
});
|
||||
await seedVideos(source.id, [moving, staying]);
|
||||
|
||||
await page.goto(`/projects/${source.id}`);
|
||||
await enterSelectionMode(page, moving);
|
||||
await page.getByRole('checkbox', { name: `Select ${moving}` }).click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to project' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Move video to another project' })
|
||||
).toBeVisible();
|
||||
|
||||
// The destination list is fetched when the dialog opens; the combobox does
|
||||
// not exist until it arrives.
|
||||
const destinationSelect = dialog.getByRole('combobox');
|
||||
await expect(destinationSelect).toBeVisible();
|
||||
await destinationSelect.click();
|
||||
await page.getByRole('option', { name: destination.name }).click();
|
||||
await dialog.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expect(page.getByText('1 video moved')).toBeVisible();
|
||||
|
||||
// Left the source...
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: moving, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: staying, level: 3 })).toBeVisible();
|
||||
|
||||
// ...and arrived in the destination. Without both halves this passes for a
|
||||
// delete as readily as for a move.
|
||||
await page.goto(`/projects/${destination.id}`);
|
||||
await expect(page.getByRole('heading', { name: moving, level: 3 })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
// What the user is shown when something fails: an upload that dies mid-flight,
|
||||
// a mutation that comes back 500, and a page whose data never arrives.
|
||||
//
|
||||
// The failures are injected with `page.route`, which is the only honest way in:
|
||||
// the server under test is a production build with no fault injection, and
|
||||
// tearing down MinIO or Postgres mid-run would take the other workers with it.
|
||||
//
|
||||
// Every test here has a positive control. Asserting "an error message appeared"
|
||||
// is worth nothing on its own, because a page that renders an error for every
|
||||
// request would pass it; each test therefore also proves the same flow succeeds
|
||||
// once the interception is removed, or that the data the failed request would
|
||||
// have changed is still exactly as it was.
|
||||
import path from 'node:path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from './fixtures';
|
||||
import { createVideo, createVersion } from '../factories';
|
||||
import { REPO_ROOT } from '../helpers/env';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
const SAMPLE_VIDEO = path.join(REPO_ROOT, 'tests', 'fixtures', 'sample.mp4');
|
||||
|
||||
test.setTimeout(120_000);
|
||||
|
||||
/** A video with an active version, cheap enough to make several of. */
|
||||
async function seedVideo(projectId: string, title: string): Promise<void> {
|
||||
const video = await createVideo({ projectId, title });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'youtube',
|
||||
providerVideoId: 'dQw4w9WgXcQ',
|
||||
title,
|
||||
duration: 120,
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts the project page into selection mode via the first card's menu. */
|
||||
async function enterSelectionMode(page: Page, anyTitle: string): Promise<void> {
|
||||
await page
|
||||
.getByRole('heading', { name: anyTitle, level: 3 })
|
||||
.locator('xpath=../..')
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Select' }).click();
|
||||
await expect(page.getByText('Selection mode')).toBeVisible();
|
||||
}
|
||||
|
||||
test('an upload that fails at the storage PUT leaves the form up and creates nothing', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const { project } = await seed.project(seededUser);
|
||||
|
||||
// Only the bytes are refused. The presign (`r2-init`) and everything else the
|
||||
// app serves still work, so the failure is exactly the one this test claims:
|
||||
// object storage rejected the upload halfway through.
|
||||
await page.route('http://minio-test:9000/**', async (route) => {
|
||||
if (route.request().method() !== 'PUT') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 500, contentType: 'text/plain', body: 'storage is down' });
|
||||
});
|
||||
|
||||
await page.goto(`/projects/${project.id}/videos/new`);
|
||||
await page.getByRole('tab', { name: 'Direct Upload' }).click();
|
||||
await page.getByLabel('Video Files').setInputFiles(SAMPLE_VIDEO);
|
||||
await page.getByLabel('Title').fill('Doomed Upload');
|
||||
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
|
||||
|
||||
// The status reaches the user rather than being flattened into "something
|
||||
// went wrong": lib/client/r2-video-upload.ts builds the message from the XHR
|
||||
// status and handleSubmit's outer catch renders `error.message` verbatim.
|
||||
// (A single file takes uploadSingleFileWithForm, which is why the message has
|
||||
// no `sample.mp4:` prefix; only the multi-file loop adds one.)
|
||||
await expect(page.getByText('Upload failed with status 500')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Still on the form, so the file list and the title survive for a retry.
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}/videos/new$`));
|
||||
await expect(page.getByLabel('Title')).toHaveValue('Doomed Upload');
|
||||
|
||||
// Nothing half-created. A version row pointing at an object that was never
|
||||
// stored would be worse than the failure itself.
|
||||
await expect.poll(() => db.video.count({ where: { projectId: project.id } })).toEqual(0);
|
||||
|
||||
// Positive control: with storage healthy the very same steps succeed, so the
|
||||
// assertions above are about the injected failure and not about the form
|
||||
// being broken.
|
||||
await page.unroute('http://minio-test:9000/**');
|
||||
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}$`), { timeout: 90_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Doomed Upload', level: 3 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a bulk delete that comes back 500 says so and leaves every video in place', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const first = `Recovery Keep A ${stamp}`;
|
||||
const second = `Recovery Keep B ${stamp}`;
|
||||
|
||||
const { project } = await seed.project(seededUser);
|
||||
await seedVideo(project.id, first);
|
||||
await seedVideo(project.id, second);
|
||||
|
||||
// A non-JSON body on purpose: it drives the client's own fallback message
|
||||
// rather than echoing a string this test supplied.
|
||||
await page.route('**/api/projects/*/videos/bulk-delete', (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'text/plain', body: 'boom' })
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${project.id}`);
|
||||
await enterSelectionMode(page, first);
|
||||
await page.getByRole('checkbox', { name: `Select ${first}` }).click();
|
||||
await page.getByRole('checkbox', { name: `Select ${second}` }).click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete selected' }).click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await dialog.getByRole('button', { name: 'Delete selected' }).click();
|
||||
|
||||
await expect(page.getByText('Failed to delete selected videos')).toBeVisible();
|
||||
|
||||
// The dialog stays open so the failed action can be retried from where it
|
||||
// was. It has to be dismissed before the cards behind it can be asserted on:
|
||||
// Radix marks everything outside an open alertdialog aria-hidden, so a
|
||||
// heading behind it is not in the accessibility tree at all.
|
||||
await expect(dialog.getByRole('button', { name: 'Delete selected' })).toBeEnabled();
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// The optimistic filter in handleDeleteSelected must not have run: both cards
|
||||
// are still on screen, and both rows are still in the database.
|
||||
await expect(page.getByRole('heading', { name: first, level: 3 })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: second, level: 3 })).toBeVisible();
|
||||
expect(await db.video.count({ where: { projectId: project.id } })).toEqual(2);
|
||||
|
||||
// Positive control: the same click succeeds once the route is released, which
|
||||
// proves the selection and the confirm dialog were driving a real request.
|
||||
await page.unroute('**/api/projects/*/videos/bulk-delete');
|
||||
await page.getByRole('button', { name: 'Delete selected' }).click();
|
||||
await dialog.getByRole('button', { name: 'Delete selected' }).click();
|
||||
|
||||
await expect(page.getByText('2 videos deleted')).toBeVisible();
|
||||
await expect.poll(() => db.video.count({ where: { projectId: project.id } })).toEqual(0);
|
||||
});
|
||||
|
||||
test('a video page whose data request 500s offers a way back instead of an empty player', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const seeded = await seed.version(seededUser, { title: `Recovery Video ${Date.now()}` });
|
||||
const videoRequest = /\/api\/projects\/[^/]+\/videos\/[^/?]+\?includeComments=false/;
|
||||
|
||||
await page.route(videoRequest, (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'text/plain', body: 'database unavailable' })
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
|
||||
|
||||
// The status is surfaced rather than swallowed into a generic spinner.
|
||||
await expect(page.getByText(/Failed to load video: 500/)).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Add a comment...')).toHaveCount(0);
|
||||
|
||||
// The escape hatch actually goes somewhere.
|
||||
await page.getByRole('link', { name: 'Back to Project' }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${seeded.project.id}$`));
|
||||
|
||||
// Positive control: without the interception the same URL renders the player
|
||||
// page, so the error state above was caused by the 500 and not by the video
|
||||
// being unreachable for some other reason.
|
||||
await page.unroute(videoRequest);
|
||||
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
|
||||
await expect(page.getByPlaceholder('Add a comment...')).toBeVisible();
|
||||
await expect(page.getByText(/Failed to load video/)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// Player interaction against a real <video> element.
|
||||
//
|
||||
// The pure arithmetic behind these controls lives in
|
||||
// components/video-page/hooks/video-player-utils.ts and is unit tested there.
|
||||
// This spec deliberately covers only what a unit test cannot: that the numbers
|
||||
// the hook computes are actually written to a media element, and that a
|
||||
// keystroke on the document reaches that element. Every assertion below reads
|
||||
// `HTMLVideoElement.currentTime` out of the browser, so a control that renders
|
||||
// but is wired to nothing fails here.
|
||||
//
|
||||
// A real file has to be uploaded first. `<video>` is rendered only for the
|
||||
// `bunny` and `r2` providers (components/video-page/player-core.tsx), and the
|
||||
// seeded `youtube` versions every other spec uses render an iframe instead, so
|
||||
// there is no media element to interrogate. The upload goes through the same
|
||||
// form video-upload.spec.ts drives, against the MinIO service in
|
||||
// docker-compose.test.yml.
|
||||
//
|
||||
// tests/fixtures/sample.mp4 is 2.0 seconds at 10 fps. Those two numbers are
|
||||
// hardcoded in the expectations below on purpose: deriving them from the file
|
||||
// at runtime would let a broken seek agree with a broken measurement.
|
||||
import path from 'node:path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from './fixtures';
|
||||
import { REPO_ROOT } from '../helpers/env';
|
||||
|
||||
const SAMPLE_VIDEO = path.join(REPO_ROOT, 'tests', 'fixtures', 'sample.mp4');
|
||||
const SAMPLE_DURATION_SECONDS = 2;
|
||||
|
||||
// One upload, three network round trips and a MinIO PUT before the first
|
||||
// assertion.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
/** `video.currentTime` as the browser currently reports it. */
|
||||
function currentTime(page: Page): Promise<number> {
|
||||
return page.locator('video').evaluate((el) => (el as HTMLVideoElement).currentTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads sample.mp4 into a fresh project and opens the video page.
|
||||
*
|
||||
* Returns nothing: everything the assertions need is read off the page.
|
||||
*/
|
||||
async function uploadAndOpen(page: Page, projectId: string, title: string): Promise<void> {
|
||||
await page.goto(`/projects/${projectId}/videos/new`);
|
||||
|
||||
const directUploadTab = page.getByRole('tab', { name: 'Direct Upload' });
|
||||
await expect(directUploadTab).toBeVisible();
|
||||
await directUploadTab.click();
|
||||
|
||||
await page.getByLabel('Video Files').setInputFiles(SAMPLE_VIDEO);
|
||||
await page.getByLabel('Title').fill(title);
|
||||
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}$`), { timeout: 90_000 });
|
||||
await page.getByRole('heading', { name: title, level: 3 }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/videos/[^/]+$`));
|
||||
}
|
||||
|
||||
/** Waits until the media element has read its metadata, then checks it is ours. */
|
||||
async function waitForMetadata(page: Page): Promise<void> {
|
||||
const video = page.locator('video');
|
||||
await expect(video).toBeVisible();
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).readyState), { timeout: 30_000 })
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
|
||||
const duration = await video.evaluate((el) => (el as HTMLVideoElement).duration);
|
||||
expect(duration).toBeGreaterThan(1.9);
|
||||
expect(duration).toBeLessThan(2.2);
|
||||
}
|
||||
|
||||
test('the timeline, the arrow keys and frame mode all move the video element', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const { project } = await seed.project(seededUser);
|
||||
await uploadAndOpen(page, project.id, `Player Video ${Date.now()}`);
|
||||
await waitForMetadata(page);
|
||||
|
||||
expect(await currentTime(page)).toEqual(0);
|
||||
await expect(page.getByText(`0:00 / 0:0${SAMPLE_DURATION_SECONDS}`)).toBeVisible();
|
||||
|
||||
// --- scrubbing ------------------------------------------------------------
|
||||
// The scrub bar carries no role, no label and no id, so it is located by the
|
||||
// class list it is built with in player-core.tsx. Reported rather than worked
|
||||
// around: a keyboard user cannot reach this control at all.
|
||||
const timeline = page.locator('div.h-8.bg-muted.cursor-pointer');
|
||||
await expect(timeline).toBeVisible();
|
||||
const box = await timeline.boundingBox();
|
||||
if (!box) throw new Error('The scrub bar has no layout box.');
|
||||
|
||||
// Three quarters along a two second video is 1.5s. mousedown alone commits
|
||||
// the seek (handleTimelineMouseDown), so a plain click is enough.
|
||||
await timeline.click({ position: { x: box.width * 0.75, y: box.height / 2 } });
|
||||
await expect.poll(() => currentTime(page)).toBeGreaterThan(1.2);
|
||||
await expect(page.getByText(`0:01 / 0:0${SAMPLE_DURATION_SECONDS}`)).toBeVisible();
|
||||
|
||||
// --- keyboard -------------------------------------------------------------
|
||||
// ArrowLeft is 'skip-back' by five seconds, clamped at zero. Starting from
|
||||
// 1.5s means the clamp is the only thing that can produce this value, and it
|
||||
// cannot be the initial state because the scrub above moved off it.
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(() => currentTime(page)).toEqual(0);
|
||||
|
||||
// ArrowRight is 'skip-forward' by five, clamped at the duration.
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => currentTime(page)).toBeGreaterThan(1.9);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(() => currentTime(page)).toEqual(0);
|
||||
|
||||
// --- frame mode -----------------------------------------------------------
|
||||
// No frame rate has been measured yet (that only happens during playback), so
|
||||
// one step is one second, and the button labels say so. The point of the
|
||||
// assertion is the *difference* from the 10s and 5s jumps above: a step that
|
||||
// lands on 1.0 could not have come from either.
|
||||
await expect(page.getByRole('button', { name: 'Forward 10s' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /^Frame / }).click();
|
||||
const forwardOneStep = page.getByRole('button', { name: 'Forward 1s' });
|
||||
await expect(forwardOneStep).toBeVisible();
|
||||
|
||||
await forwardOneStep.click();
|
||||
await expect.poll(() => currentTime(page)).toBeGreaterThan(0.9);
|
||||
expect(await currentTime(page)).toBeLessThan(1.2);
|
||||
|
||||
await page.getByRole('button', { name: 'Back 1s' }).click();
|
||||
await expect.poll(() => currentTime(page)).toEqual(0);
|
||||
});
|
||||
|
||||
test('the arrow keys are not hijacked while a comment is being typed', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const { project } = await seed.project(seededUser);
|
||||
await uploadAndOpen(page, project.id, `Player Typing ${Date.now()}`);
|
||||
await waitForMetadata(page);
|
||||
|
||||
const composer = page.getByPlaceholder('Add a comment...');
|
||||
await composer.fill('cursor keys belong to this box');
|
||||
await composer.click();
|
||||
// Put the caret in the middle so ArrowLeft has somewhere to go inside the
|
||||
// field; if the player claimed the key, the video would seek instead.
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
expect(await currentTime(page)).toEqual(0);
|
||||
await expect(composer).toHaveValue('cursor keys belong to this box');
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
// Workspace member management, driven entirely from the browser.
|
||||
//
|
||||
// The API behind /api/workspaces/:id/members is well covered by the api suite.
|
||||
// What is not covered anywhere is the round trip: an owner invites someone in
|
||||
// one browser, that person accepts in another, and the role they end up with
|
||||
// decides what their pages render. This spec asserts on the *other* account's
|
||||
// view after every change the owner makes, because a permission change that the
|
||||
// owner's own page reports but the member's browser never sees is exactly the
|
||||
// bug an api-level test cannot find.
|
||||
//
|
||||
// Two things about the invite flow are worth knowing before reading on:
|
||||
//
|
||||
// 1. Inviting never adds a member directly, even when the address already
|
||||
// belongs to an account. app/api/workspaces/[workspaceId]/members/route.ts
|
||||
// always creates an Invitation and emails a link.
|
||||
// 2. SMTP is deliberately unset for this suite (see playwright.config.ts), so
|
||||
// nothing is delivered. The token is read out of the database instead. That
|
||||
// is the one shortcut here; everything on either side of it goes through the
|
||||
// UI.
|
||||
import { test, expect, storageStateFor, type StorageState } from './fixtures';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* The invitation link the owner's invite would have emailed.
|
||||
*
|
||||
* Scoped to the address the test just invited, so it cannot pick up a row from
|
||||
* a parallel worker.
|
||||
*/
|
||||
async function invitationTokenFor(email: string): Promise<string> {
|
||||
const invitation = await db.invitation.findFirst({
|
||||
where: { email, scope: 'WORKSPACE', status: 'PENDING' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { token: true },
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new Error(`No pending workspace invitation was created for ${email}.`);
|
||||
}
|
||||
return invitation.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The member row for `email` in the Current Members card.
|
||||
*
|
||||
* The rows carry no role, no heading and no test id, so the only stable anchor
|
||||
* is the address itself: the <p> holding it, then three levels up to the row
|
||||
* div that also holds the role select and the remove button. See
|
||||
* components/members-management-page.tsx.
|
||||
*/
|
||||
function memberRowFor(page: import('@playwright/test').Page, email: string) {
|
||||
return page.getByText(email, { exact: true }).locator('xpath=ancestor::div[3]');
|
||||
}
|
||||
|
||||
test('an invited member accepts, is promoted, and is removed, and their own pages follow', async ({
|
||||
page,
|
||||
browser,
|
||||
playwright,
|
||||
baseURL,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
// `confirm()` guards the remove button. Playwright dismisses dialogs by
|
||||
// default, which would make the DELETE never fire and the assertion below
|
||||
// fail for a reason that has nothing to do with the product.
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
|
||||
const workspace = await seed.workspace(seededUser);
|
||||
const member = await seed.user({ name: 'Invited Reviewer' });
|
||||
const memberEmail = member.email ?? '';
|
||||
expect(memberEmail).not.toEqual('');
|
||||
|
||||
const memberState: StorageState = await storageStateFor(
|
||||
playwright.request,
|
||||
baseURL ?? '',
|
||||
memberEmail
|
||||
);
|
||||
const memberContext = await browser.newContext({ storageState: memberState });
|
||||
|
||||
try {
|
||||
const memberPage = await memberContext.newPage();
|
||||
|
||||
// --- before the invitation ----------------------------------------------
|
||||
// A stranger to the workspace is bounced off it entirely. This is the
|
||||
// control for every "the member can see it now" assertion further down.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}`);
|
||||
await expect(memberPage).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// --- the owner invites --------------------------------------------------
|
||||
await page.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(page.getByText('No members yet. Invite someone above.')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email Address').fill(memberEmail);
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
await expect(page.getByText(`Invitation sent to ${memberEmail}`)).toBeVisible();
|
||||
// The pending list is the owner-visible proof that a row was written; the
|
||||
// success banner alone would also appear for a no-op.
|
||||
await expect(page.getByText('No pending invitations.')).toHaveCount(0);
|
||||
await expect(page.getByText(memberEmail, { exact: true })).toBeVisible();
|
||||
|
||||
// --- the member accepts -------------------------------------------------
|
||||
const token = await invitationTokenFor(memberEmail);
|
||||
await memberPage.goto(`/invitations/accept?token=${token}`);
|
||||
|
||||
// Accepting lands on the workspace it was for, which is itself the first
|
||||
// proof that the membership row now exists: the same URL redirected to
|
||||
// /dashboard a moment ago.
|
||||
await expect(memberPage).toHaveURL(
|
||||
new RegExp(`/workspaces/${workspace.id}\\?invite=accepted$`)
|
||||
);
|
||||
await expect(memberPage.getByRole('heading', { name: workspace.name })).toBeVisible();
|
||||
|
||||
// COMMENTATOR is the role that was sent, so the management controls must
|
||||
// not be there.
|
||||
await expect(memberPage.getByRole('link', { name: 'Members' })).toHaveCount(0);
|
||||
await expect(memberPage.getByRole('link', { name: 'Settings' })).toHaveCount(0);
|
||||
|
||||
// And the page behind that button is refused, not merely unlinked.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(memberPage).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// --- the owner promotes them to ADMIN -----------------------------------
|
||||
await page.reload();
|
||||
const memberRow = memberRowFor(page, memberEmail);
|
||||
const roleSelect = memberRow.getByRole('combobox');
|
||||
await expect(roleSelect).toContainText('Commentator');
|
||||
|
||||
await roleSelect.click();
|
||||
await page.getByRole('option', { name: 'Admin' }).click();
|
||||
await expect(roleSelect).toContainText('Admin');
|
||||
|
||||
// The member's own browser has to see the new role, not just the owner's.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}`);
|
||||
await expect(memberPage.getByRole('link', { name: 'Members' })).toBeVisible();
|
||||
|
||||
await memberPage.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(memberPage.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||
// An admin sees the owner as well as themselves, so the empty-state line is
|
||||
// the wrong thing to look for; the owner's address is the right one.
|
||||
await expect(memberPage.getByText(seededUser.email ?? '', { exact: true })).toBeVisible();
|
||||
|
||||
// --- the owner removes them ---------------------------------------------
|
||||
await page.reload();
|
||||
const rowToRemove = memberRowFor(page, memberEmail);
|
||||
await rowToRemove.getByRole('button').last().click();
|
||||
|
||||
await expect(page.getByText('No members yet. Invite someone above.')).toBeVisible();
|
||||
|
||||
// Back to where the spec started: no access at all.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}`);
|
||||
await expect(memberPage).toHaveURL(/\/dashboard$/);
|
||||
} finally {
|
||||
await memberContext.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a commentator cannot invite anyone, and the owner can withdraw a pending invitation', async ({
|
||||
page,
|
||||
browser,
|
||||
playwright,
|
||||
baseURL,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const workspace = await seed.workspace(seededUser);
|
||||
const commentator = await seed.user({ name: 'Commentator Only' });
|
||||
const commentatorEmail = commentator.email ?? '';
|
||||
|
||||
// Seeded directly rather than invited through the UI: the invite-then-accept
|
||||
// path is what the test above exists for, and repeating it here would double
|
||||
// this spec's runtime to set up a precondition.
|
||||
await db.workspaceMember.create({
|
||||
data: { workspaceId: workspace.id, userId: commentator.id, role: 'COMMENTATOR' },
|
||||
});
|
||||
|
||||
const commentatorContext = await browser.newContext({
|
||||
storageState: await storageStateFor(playwright.request, baseURL ?? '', commentatorEmail),
|
||||
});
|
||||
|
||||
try {
|
||||
const commentatorPage = await commentatorContext.newPage();
|
||||
|
||||
// The member page is the only route to the invite form, and 'manage' intent
|
||||
// sends a commentator away from it.
|
||||
await commentatorPage.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(commentatorPage).toHaveURL(/\/dashboard$/);
|
||||
await expect(commentatorPage.getByLabel('Email Address')).toHaveCount(0);
|
||||
|
||||
// --- the owner invites a third party and then withdraws it --------------
|
||||
const outsiderEmail = `e2e-withdrawn-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto(`/workspaces/${workspace.id}/members`);
|
||||
await page.getByLabel('Email Address').fill(outsiderEmail);
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await expect(page.getByText(`Invitation sent to ${outsiderEmail}`)).toBeVisible();
|
||||
|
||||
const invitationRow = page
|
||||
.getByText(outsiderEmail, { exact: true })
|
||||
.locator('xpath=ancestor::div[2]');
|
||||
await invitationRow.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(page.getByText('Invitation canceled')).toBeVisible();
|
||||
await expect(page.getByText('No pending invitations.')).toBeVisible();
|
||||
|
||||
// Withdrawn for real: the token that was minted no longer opens anything.
|
||||
const withdrawn = await db.invitation.findFirst({
|
||||
where: { email: outsiderEmail },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(withdrawn?.status).toEqual('CANCELED');
|
||||
} finally {
|
||||
// The invitation row hangs off the workspace, which hangs off the owner, so
|
||||
// the Seed's own user cleanup takes it with it.
|
||||
await commentatorContext.close();
|
||||
}
|
||||
});
|
||||
+37
-10
@@ -74,16 +74,43 @@ vi.mock('@/lib/r2', async (importOriginal) => {
|
||||
uploadAudio: vi.fn(async (key: string) => `https://r2.test/object/${key}`),
|
||||
deleteVideoObject: vi.fn(async () => undefined),
|
||||
deleteR2Object: vi.fn(async () => undefined),
|
||||
headVideoObject: vi.fn(async () => ({
|
||||
contentLength: 1024,
|
||||
contentType: 'video/mp4',
|
||||
etag: 'test-etag',
|
||||
})),
|
||||
readVideoObjectBytes: vi.fn(async () => ({
|
||||
body: new Uint8Array(0),
|
||||
contentLength: 0,
|
||||
contentType: 'video/mp4',
|
||||
})),
|
||||
// These two must match the real return shapes exactly, and for a while they
|
||||
// did not. `headVideoObject` really answers `contentLength: bigint`, and
|
||||
// `readVideoObjectBytes` really answers `Uint8Array | null`, not an object
|
||||
// wrapping one.
|
||||
//
|
||||
// The wrong shapes were not inert. `finalizeR2VideoUpload` passes the head
|
||||
// result straight through as `sizeBytes`, so a `number` reached a BigInt
|
||||
// column and only survived on Prisma's coercion. Worse, the old
|
||||
// `readVideoObjectBytes` stub returned a truthy object with no `.length`,
|
||||
// so `hasKnownVideoMagicBytes()` saw zero bytes and every route reaching
|
||||
// finalize took the "Uploaded file is not a valid video" branch, cancelled
|
||||
// the session and deleted both objects. Nothing caught it because no test
|
||||
// drove that path to success until tests/api/lib-r2-video-finalize.test.ts.
|
||||
// Both keep the guards the real functions apply before they ever speak to
|
||||
// S3: neither will touch a key outside the `videos/` prefix, and
|
||||
// readVideoObjectBytes also refuses a non-positive length (lib/r2.ts:407 and
|
||||
// :440). A stub that answers for any key disarms those guards for every api
|
||||
// suite at once, so a route that heads or reads the wrong object key would
|
||||
// look perfectly healthy. The prefix is written out here rather than imported
|
||||
// from lib/video-upload-validation, so that changing it fails loudly instead
|
||||
// of the stub agreeing with the change.
|
||||
headVideoObject: vi.fn(async (key: string) => {
|
||||
if (!key.startsWith('videos/')) return null;
|
||||
return { contentLength: BigInt(1024), contentType: 'video/mp4' };
|
||||
}),
|
||||
// 64 bytes with an `ftyp` box at offset 4, the ISO base media signature the
|
||||
// first branch of hasKnownVideoMagicBytes() looks for, trimmed to the length
|
||||
// the caller asked for the way a real ranged GET would be. A suite that needs
|
||||
// a rejected upload overrides this per test.
|
||||
readVideoObjectBytes: vi.fn(async (key: string, byteLength: number) => {
|
||||
if (!key.startsWith('videos/') || byteLength <= 0) return null;
|
||||
const header = new Uint8Array(64);
|
||||
header.set([0x00, 0x00, 0x00, 0x20], 0);
|
||||
header.set([0x66, 0x74, 0x79, 0x70], 4); // 'ftyp'
|
||||
header.set([0x69, 0x73, 0x6f, 0x6d], 8); // 'isom'
|
||||
return header.slice(0, Math.min(header.length, byteLength));
|
||||
}),
|
||||
ensureR2BucketExists: vi.fn(async () => undefined),
|
||||
ensureR2UploadCors: vi.fn(async () => []),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resolvePublicBunnyCdnHostname, resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
beforeEach(() => {
|
||||
// The `unit` project loads no env file, so pin both variables rather than
|
||||
// inheriting whatever the shell exports.
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('resolveServerBunnyCdnHostname', () => {
|
||||
it('returns null when neither variable is configured', () => {
|
||||
expect(resolveServerBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the server variable over the public one', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('server.b-cdn.net');
|
||||
});
|
||||
|
||||
it('falls back to the public variable when the server one is unset', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('public.b-cdn.net');
|
||||
});
|
||||
|
||||
it('falls back to the public variable when the server one is empty', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', '');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('public.b-cdn.net');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a full https url', 'https://cdn.example.b-cdn.net', 'cdn.example.b-cdn.net'],
|
||||
['an http url', 'http://cdn.example.b-cdn.net', 'cdn.example.b-cdn.net'],
|
||||
['a url with a path', 'https://cdn.example.b-cdn.net/videos', 'cdn.example.b-cdn.net'],
|
||||
['a url with a trailing slash', 'https://cdn.example.b-cdn.net/', 'cdn.example.b-cdn.net'],
|
||||
['a url with a query string', 'https://cdn.example.b-cdn.net/?a=1', 'cdn.example.b-cdn.net'],
|
||||
['a bare hostname', 'cdn.example.b-cdn.net', 'cdn.example.b-cdn.net'],
|
||||
['a bare hostname with a trailing slash', 'cdn.example.b-cdn.net/', 'cdn.example.b-cdn.net'],
|
||||
[
|
||||
'a scheme-less url written with slashes',
|
||||
'//cdn.example.b-cdn.net',
|
||||
'//cdn.example.b-cdn.net',
|
||||
],
|
||||
['surrounding whitespace', ' https://cdn.example.b-cdn.net ', 'cdn.example.b-cdn.net'],
|
||||
])('reduces %s to the hostname', (_label, configured, expected) => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', configured);
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe(expected);
|
||||
});
|
||||
|
||||
it('drops the port from a url that carries one', () => {
|
||||
// `URL.hostname` excludes the port, unlike `URL.host`. Callers that compare
|
||||
// this value against a request hostname get the bare host, which is what the
|
||||
// Bunny CDN always serves on.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://cdn.example.b-cdn.net:8443/videos');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('cdn.example.b-cdn.net');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['whitespace only', ' '],
|
||||
])('returns null for %s', (_label, configured) => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', configured);
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a bare host:port, which parses as a url with no hostname', () => {
|
||||
// `new URL('localhost:9000')` succeeds with protocol `localhost:` and an
|
||||
// empty hostname, so it never reaches the string-stripping fallback.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'localhost:9000');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a path attached when the value has no scheme to parse', () => {
|
||||
// The fallback only strips a leading scheme and trailing slashes, so a
|
||||
// scheme-less value with a path is returned as-is rather than as a hostname.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'cdn.example.b-cdn.net/videos');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('cdn.example.b-cdn.net/videos');
|
||||
});
|
||||
|
||||
it('never returns a value carrying a scheme', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://cdn.example.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).not.toContain('://');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePublicBunnyCdnHostname', () => {
|
||||
it('returns null when the public variable is unset', () => {
|
||||
expect(resolvePublicBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads only the public variable, ignoring the server-only one', () => {
|
||||
// This runs in the browser bundle, where BUNNY_CDN_URL is never inlined.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net');
|
||||
|
||||
expect(resolvePublicBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('reduces the configured public url to its hostname', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net/videos/');
|
||||
|
||||
expect(resolvePublicBunnyCdnHostname()).toBe('public.b-cdn.net');
|
||||
});
|
||||
|
||||
it('returns the same hostname as the server resolver when only the public url is set', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolvePublicBunnyCdnHostname()).toBe(resolveServerBunnyCdnHostname());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
// Bunny download source resolution. Everything the module decides is a
|
||||
// function of what the CDN answers to a HEAD, so `fetch` is the only boundary
|
||||
// stubbed here. Urls are asserted in full because they are fully deterministic;
|
||||
// nothing in this module is signed.
|
||||
//
|
||||
// The module keeps a 60 second in-process cache keyed on
|
||||
// videoId:quality:preference, so every test uses its own video id unless it is
|
||||
// deliberately exercising the cache.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
resolveBunnyCdnHostname,
|
||||
resolveBunnyDownloadSource,
|
||||
} from '@/lib/bunny-download';
|
||||
|
||||
const HOST = 'cdn.example.b-cdn.net';
|
||||
|
||||
type FetchCall = [string, RequestInit];
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function ok(body = ''): Response {
|
||||
return { ok: true, status: 200, text: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
function notFound(): Response {
|
||||
return { ok: false, status: 404, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer 200 for the listed urls and 404 for everything else. `playlist` is
|
||||
* served as the body of any playlist.m3u8 request.
|
||||
*/
|
||||
function stubCdn(available: string[], playlist?: string): void {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/playlist.m3u8')) {
|
||||
return playlist === undefined ? notFound() : ok(playlist);
|
||||
}
|
||||
return available.includes(url) ? ok() : notFound();
|
||||
});
|
||||
}
|
||||
|
||||
function requestedUrls(): string[] {
|
||||
return (fetchMock.mock.calls as FetchCall[]).map((call) => call[0]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async () => notFound());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubEnv('BUNNY_CDN_URL', `https://${HOST}`);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('resolveBunnyCdnHostname', () => {
|
||||
it('reduces a configured url to its hostname', () => {
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('drops a path and a trailing slash', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', `https://${HOST}/some/path/`);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('accepts a bare hostname with no scheme', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', `${HOST}/`);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('falls back to the public variable', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', `https://${HOST}`);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('returns null when neither variable is set', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a blank value rather than an empty hostname', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', ' ');
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no CDN configured', () => {
|
||||
it('resolves to null without making a request', async () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-nohost', null, 'auto')).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the original preference', () => {
|
||||
it('returns the original url when the CDN has one', async () => {
|
||||
stubCdn([`https://${HOST}/vid-orig-1/original`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-orig-1', null, 'original')).resolves.toEqual({
|
||||
sourceType: 'original',
|
||||
quality: null,
|
||||
url: `https://${HOST}/vid-orig-1/original`,
|
||||
});
|
||||
});
|
||||
|
||||
// Asking for the original explicitly means the caller wants the master file
|
||||
// or nothing; falling back to a transcode would silently hand back a
|
||||
// lower-quality file under the same name.
|
||||
it('returns null rather than a transcode when the original is absent', async () => {
|
||||
stubCdn([`https://${HOST}/vid-orig-2/play_1080p.mp4`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-orig-2', null, 'original')).resolves.toBeNull();
|
||||
expect(requestedUrls()).toEqual([`https://${HOST}/vid-orig-2/original`]);
|
||||
});
|
||||
|
||||
it('probes with a HEAD that bypasses the cache', async () => {
|
||||
stubCdn([`https://${HOST}/vid-orig-3/original`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-orig-3', null, 'original');
|
||||
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'HEAD', cache: 'no-store' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the compressed preference', () => {
|
||||
it('never asks for the original', async () => {
|
||||
stubCdn([`https://${HOST}/vid-comp-1/original`, `https://${HOST}/vid-comp-1/play_720p.mp4`]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-comp-1', null, 'compressed');
|
||||
|
||||
expect(source?.sourceType).toBe('compressed');
|
||||
expect(requestedUrls().some((url) => url.endsWith('/original'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the requested quality when that rendition exists', async () => {
|
||||
stubCdn([
|
||||
`https://${HOST}/vid-comp-2/play_720p.mp4`,
|
||||
`https://${HOST}/vid-comp-2/play_1080p.mp4`,
|
||||
]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-comp-2', 720, 'compressed')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: 720,
|
||||
url: `https://${HOST}/vid-comp-2/play_720p.mp4`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the highest available rendition when the requested one is missing', async () => {
|
||||
stubCdn([`https://${HOST}/vid-comp-3/play_480p.mp4`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-comp-3', 1080, 'compressed')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: 480,
|
||||
url: `https://${HOST}/vid-comp-3/play_480p.mp4`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, -720, Number.NaN])(
|
||||
'ignores a requested quality of %s and goes straight to the fallback',
|
||||
async (quality) => {
|
||||
stubCdn([`https://${HOST}/vid-comp-q${quality}/play_360p.mp4`]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource(
|
||||
`vid-comp-q${quality}`,
|
||||
quality,
|
||||
'compressed'
|
||||
);
|
||||
|
||||
expect(source?.quality).toBe(360);
|
||||
expect(requestedUrls().some((url) => url.includes(`play_${quality}p`))).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports an empty url when nothing is available at all', async () => {
|
||||
stubCdn([]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-comp-4', null, 'compressed')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: null,
|
||||
url: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('walks the fallback ladder from highest to lowest', async () => {
|
||||
stubCdn([]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-comp-5', null, 'compressed');
|
||||
|
||||
expect(requestedUrls()).toEqual([
|
||||
`https://${HOST}/vid-comp-5/playlist.m3u8`,
|
||||
`https://${HOST}/vid-comp-5/play_2160p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_1440p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_1080p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_720p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_480p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_360p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_240p.mp4`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the playlist hint', () => {
|
||||
it('tries the heights the playlist advertises before the static ladder', async () => {
|
||||
stubCdn(
|
||||
[`https://${HOST}/vid-pl-1/play_720p.mp4`],
|
||||
'#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=1280x720\n720.m3u8\n'
|
||||
);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-pl-1', null, 'compressed');
|
||||
|
||||
expect(source?.url).toBe(`https://${HOST}/vid-pl-1/play_720p.mp4`);
|
||||
// 720 came from the playlist, so it is probed before 2160.
|
||||
expect(requestedUrls()[1]).toBe(`https://${HOST}/vid-pl-1/play_720p.mp4`);
|
||||
});
|
||||
|
||||
it('sorts the advertised heights from highest to lowest', async () => {
|
||||
stubCdn(
|
||||
[],
|
||||
'#EXT-X-STREAM-INF:RESOLUTION=640x360\na\n#EXT-X-STREAM-INF:RESOLUTION=1920x1080\nb\n'
|
||||
);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-pl-2', null, 'compressed');
|
||||
|
||||
expect(requestedUrls().slice(1, 3)).toEqual([
|
||||
`https://${HOST}/vid-pl-2/play_1080p.mp4`,
|
||||
`https://${HOST}/vid-pl-2/play_360p.mp4`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an advertised height that is not a Bunny rendition', async () => {
|
||||
stubCdn([], '#EXT-X-STREAM-INF:RESOLUTION=1600x900\na\n');
|
||||
|
||||
await resolveBunnyDownloadSource('vid-pl-3', null, 'compressed');
|
||||
|
||||
expect(requestedUrls().some((url) => url.includes('play_900p'))).toBe(false);
|
||||
expect(requestedUrls()[1]).toBe(`https://${HOST}/vid-pl-3/play_2160p.mp4`);
|
||||
});
|
||||
|
||||
it('does not probe a playlist height twice when the ladder repeats it', async () => {
|
||||
stubCdn([], '#EXT-X-STREAM-INF:RESOLUTION=1920x1080\na\n');
|
||||
|
||||
await resolveBunnyDownloadSource('vid-pl-4', null, 'compressed');
|
||||
|
||||
const probes = requestedUrls().filter((url) => url.includes('play_1080p'));
|
||||
expect(probes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to the static ladder when the playlist request fails', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/playlist.m3u8')) throw new Error('connection reset');
|
||||
return url.endsWith('play_1440p.mp4') ? ok() : notFound();
|
||||
});
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-pl-5', null, 'compressed');
|
||||
|
||||
expect(source?.quality).toBe(1440);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the auto preference', () => {
|
||||
it('prefers the original when the CDN has one', async () => {
|
||||
stubCdn([`https://${HOST}/vid-auto-1/original`, `https://${HOST}/vid-auto-1/play_1080p.mp4`]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-auto-1', null, 'auto');
|
||||
|
||||
expect(source).toEqual({
|
||||
sourceType: 'original',
|
||||
quality: null,
|
||||
url: `https://${HOST}/vid-auto-1/original`,
|
||||
});
|
||||
expect(requestedUrls()).toEqual([`https://${HOST}/vid-auto-1/original`]);
|
||||
});
|
||||
|
||||
it('falls through to a transcode when there is no original', async () => {
|
||||
stubCdn([`https://${HOST}/vid-auto-2/play_1080p.mp4`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-auto-2', null, 'auto')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: 1080,
|
||||
url: `https://${HOST}/vid-auto-2/play_1080p.mp4`,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours the requested quality on the fall-through path', async () => {
|
||||
stubCdn([
|
||||
`https://${HOST}/vid-auto-3/play_480p.mp4`,
|
||||
`https://${HOST}/vid-auto-3/play_1080p.mp4`,
|
||||
]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-auto-3', 480, 'auto');
|
||||
|
||||
expect(source?.url).toBe(`https://${HOST}/vid-auto-3/play_480p.mp4`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availability probing', () => {
|
||||
it('retries with a ranged GET when the CDN refuses HEAD', async () => {
|
||||
fetchMock.mockImplementation(async (url: string, init: RequestInit) => {
|
||||
if (init.method === 'HEAD') return { ok: false, status: 405 } as unknown as Response;
|
||||
return { ok: false, status: 206 } as unknown as Response;
|
||||
});
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-probe-1', null, 'original');
|
||||
|
||||
expect(source?.url).toBe(`https://${HOST}/vid-probe-1/original`);
|
||||
expect(fetchMock.mock.calls[1][1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Range: 'bytes=0-0' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a plain 200 on the ranged retry too', async () => {
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) =>
|
||||
init.method === 'HEAD'
|
||||
? ({ ok: false, status: 405 } as unknown as Response)
|
||||
: ({ ok: true, status: 200 } as unknown as Response)
|
||||
);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-probe-2', null, 'original');
|
||||
|
||||
expect(source).not.toBeNull();
|
||||
});
|
||||
|
||||
it('treats the file as absent when the ranged retry also fails', async () => {
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) =>
|
||||
init.method === 'HEAD'
|
||||
? ({ ok: false, status: 405 } as unknown as Response)
|
||||
: ({ ok: false, status: 403 } as unknown as Response)
|
||||
);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-probe-3', null, 'original')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not retry a status other than 405', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 403 } as unknown as Response);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-probe-4', null, 'original');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a rejected probe as absent rather than propagating it', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('dns failure'));
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-probe-5', null, 'original')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the resolution cache', () => {
|
||||
it('serves a repeat lookup without touching the CDN again', async () => {
|
||||
stubCdn([`https://${HOST}/vid-cache-1/original`]);
|
||||
|
||||
const first = await resolveBunnyDownloadSource('vid-cache-1', null, 'auto');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
const second = await resolveBunnyDownloadSource('vid-cache-1', null, 'auto');
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('caches a negative result so a missing original is not re-probed', async () => {
|
||||
stubCdn([]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-2', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
await expect(resolveBunnyDownloadSource('vid-cache-2', null, 'original')).resolves.toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('keys the cache on the preference', async () => {
|
||||
stubCdn([`https://${HOST}/vid-cache-3/play_720p.mp4`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-3', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
await resolveBunnyDownloadSource('vid-cache-3', null, 'compressed');
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('keys the cache on the requested quality', async () => {
|
||||
stubCdn([
|
||||
`https://${HOST}/vid-cache-4/play_720p.mp4`,
|
||||
`https://${HOST}/vid-cache-4/play_1080p.mp4`,
|
||||
]);
|
||||
|
||||
const low = await resolveBunnyDownloadSource('vid-cache-4', 720, 'compressed');
|
||||
const high = await resolveBunnyDownloadSource('vid-cache-4', 1080, 'compressed');
|
||||
|
||||
expect(low?.quality).toBe(720);
|
||||
expect(high?.quality).toBe(1080);
|
||||
});
|
||||
|
||||
it('re-probes once the sixty second window has passed', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'));
|
||||
stubCdn([`https://${HOST}/vid-cache-5/original`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-5', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-15T00:01:00.001Z'));
|
||||
await resolveBunnyDownloadSource('vid-cache-5', null, 'original');
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('still serves from the cache one millisecond before expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'));
|
||||
stubCdn([`https://${HOST}/vid-cache-6/original`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-6', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:59.999Z'));
|
||||
await resolveBunnyDownloadSource('vid-cache-6', null, 'original');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWithTimeout', () => {
|
||||
it('passes an abort signal through to fetch', async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
|
||||
await fetchWithTimeout('https://example.com/a', { method: 'HEAD' });
|
||||
|
||||
const init = (fetchMock.mock.calls[0] as FetchCall)[1];
|
||||
expect(init.method).toBe('HEAD');
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('aborts a request that has not answered within eight seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
// Never settles, so the only thing that can end the request is the timeout.
|
||||
fetchMock.mockImplementation((_url: string, init: RequestInit) => {
|
||||
signal = init.signal ?? undefined;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
|
||||
void fetchWithTimeout('https://example.com/slow', {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7999);
|
||||
expect(signal?.aborted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not abort a request that answered in time', async () => {
|
||||
vi.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
signal = init.signal ?? undefined;
|
||||
return ok();
|
||||
});
|
||||
|
||||
await fetchWithTimeout('https://example.com/fast', {});
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
|
||||
expect(signal?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
// Orphan deletion on the Bunny side. Every assertion here is really the same
|
||||
// question asked from a different angle: does this module ever issue a DELETE
|
||||
// for something it was not handed as a live Bunny reference?
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type BunnyVideoRef,
|
||||
cleanupBunnyStreamVideos,
|
||||
cleanupBunnyStreamVideosBestEffort,
|
||||
} from '@/lib/bunny-stream-cleanup';
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function deletedIds(): string[] {
|
||||
return fetchMock.mock.calls.map((call) => String(call[0]).split('/videos/')[1]);
|
||||
}
|
||||
|
||||
function bunnyRefs(...videoIds: string[]): BunnyVideoRef[] {
|
||||
return videoIds.map((videoId) => ({ providerId: 'bunny', videoId }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-api-key-unit');
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('which references get deleted', () => {
|
||||
// The provider filter is the safety net. A reference that belongs to another
|
||||
// provider names a live object somewhere else; deleting it by Bunny id would
|
||||
// be meaningless at best, and the same guard is what stops a caller passing a
|
||||
// mixed list from wiping rows it only meant to inspect.
|
||||
it.each(['r2', 'youtube', 'direct', 'BUNNY', ''])(
|
||||
'never deletes a reference whose provider is %s',
|
||||
async (providerId) => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort([
|
||||
{ providerId, videoId: 'live-video-id-1' },
|
||||
]);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ attempted: 0, failed: 0, failedIds: [] });
|
||||
}
|
||||
);
|
||||
|
||||
it('deletes only the Bunny references out of a mixed list', async () => {
|
||||
await cleanupBunnyStreamVideosBestEffort([
|
||||
{ providerId: 'r2', videoId: 'r2-object-key-1' },
|
||||
{ providerId: 'bunny', videoId: 'bunny-video-id-1' },
|
||||
{ providerId: 'youtube', videoId: 'dQw4w9WgXcQ' },
|
||||
]);
|
||||
|
||||
expect(deletedIds()).toEqual(['bunny-video-id-1']);
|
||||
});
|
||||
|
||||
it('does nothing at all for an empty list', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort([]);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ attempted: 0, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it('skips a Bunny reference with an empty video id', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs(''));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result.attempted).toBe(0);
|
||||
});
|
||||
|
||||
// The id goes straight into the request path, so anything that is not the
|
||||
// Bunny guid alphabet is dropped rather than sent.
|
||||
it.each([
|
||||
['too short', 'abc1234'],
|
||||
['a path traversal', '../../library/1/videos/other'],
|
||||
['a slash', 'bunny/video'],
|
||||
['a space', 'bunny video id'],
|
||||
['a wildcard', '*'],
|
||||
['a sql fragment', "abcdefgh'; DROP TABLE videos; --"],
|
||||
['over 128 characters', 'a'.repeat(129)],
|
||||
])('skips an id containing %s', async (_label, videoId) => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs(videoId));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ attempted: 0, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it.each([8, 128])('accepts an id of exactly %i characters', async (length) => {
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs('a'.repeat(length)));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before validating and sending', async () => {
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs(' bunny-video-id-1 '));
|
||||
|
||||
expect(deletedIds()).toEqual(['bunny-video-id-1']);
|
||||
});
|
||||
|
||||
it('deletes a repeated id once', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-1', ' bunny-video-id-1 ')
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.attempted).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the delete request', () => {
|
||||
it('sends a keyed DELETE to the library video endpoint', async () => {
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://video.bunnycdn.com/library/4242/videos/bunny-video-id-1',
|
||||
{ method: 'DELETE', headers: { AccessKey: 'bunny-api-key-unit' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the public library id when the server one is unset', async () => {
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', '9001');
|
||||
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('/library/9001/videos/');
|
||||
});
|
||||
|
||||
it.each(['BUNNY_STREAM_API_KEY', 'BUNNY_STREAM_LIBRARY_ID'])(
|
||||
'reports every id as failed when %s is missing',
|
||||
async (missing) => {
|
||||
vi.stubEnv(missing, undefined);
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-2')
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
attempted: 2,
|
||||
failed: 2,
|
||||
failedIds: ['bunny-video-id-1', 'bunny-video-id-2'],
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('how each response is scored', () => {
|
||||
it('counts a 2xx as deleted', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it('counts a 404 as already deleted rather than a failure', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it.each([401, 403, 429, 500])('counts a %i as a failure', async (status) => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status });
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 1, failedIds: ['bunny-video-id-1'] });
|
||||
});
|
||||
|
||||
it('counts a rejected request as a failure', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('socket hang up'));
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 1, failedIds: ['bunny-video-id-1'] });
|
||||
});
|
||||
|
||||
it('keeps deleting the rest after one id fails', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
url.endsWith('bunny-video-id-2') ? { ok: false, status: 500 } : { ok: true, status: 200 }
|
||||
);
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-2', 'bunny-video-id-3')
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual({ attempted: 3, failed: 1, failedIds: ['bunny-video-id-2'] });
|
||||
});
|
||||
|
||||
it('holds at most five deletes in flight', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
fetchMock.mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
return new Promise((resolve) => {
|
||||
release.push(() => {
|
||||
inFlight -= 1;
|
||||
resolve({ ok: true, status: 200 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const ids = Array.from({ length: 12 }, (_unused, index) => `bunny-video-id-${index + 100}`);
|
||||
const pending = cleanupBunnyStreamVideosBestEffort(bunnyRefs(...ids));
|
||||
|
||||
// Drain in waves: whatever is queued right now, then whatever that unblocks.
|
||||
while (release.length > 0) {
|
||||
release.shift()!();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await pending;
|
||||
|
||||
expect(peak).toBe(5);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupBunnyStreamVideos', () => {
|
||||
it('resolves when every delete succeeded', async () => {
|
||||
await expect(cleanupBunnyStreamVideos(bunnyRefs('bunny-video-id-1'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves when there was nothing to delete', async () => {
|
||||
await expect(cleanupBunnyStreamVideos([])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws with a count and the first three failed ids', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
await expect(
|
||||
cleanupBunnyStreamVideos(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-2', 'bunny-video-id-3', 'bunny-video-id-4')
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Bunny cleanup failed for 4 video(s): bunny-video-id-1, bunny-video-id-2, bunny-video-id-3'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
|
||||
const SECRET = 'bunny-upload-token-test-secret';
|
||||
const OTHER_SECRET = 'a-completely-different-secret';
|
||||
const NOW = new Date('2026-01-15T12:00:00.000Z');
|
||||
const NOW_SECONDS = Math.floor(NOW.getTime() / 1000);
|
||||
const ONE_HOUR = 60 * 60;
|
||||
|
||||
const SUBJECT = {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
videoId: 'video-1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mints a token over an arbitrary payload with a valid signature. No signature is
|
||||
* ever hardcoded here, because it depends on the configured secret; every
|
||||
* expectation is about behaviour. This helper exists only to reach the
|
||||
* payload-shape checks, which a forged signature can never get past.
|
||||
*/
|
||||
function signArbitrary(payload: unknown, secret = SECRET): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs raw JSON text rather than an object. JSON.stringify cannot emit a
|
||||
* non-finite number, so this is the only way to hand verify() a payload whose
|
||||
* `iat` or `exp` parses back as Infinity: a decimal exponent that overflows to
|
||||
* it, which JSON.parse accepts and turns into Infinity.
|
||||
*/
|
||||
function signRawJson(json: string, secret = SECRET): string {
|
||||
const encoded = Buffer.from(json, 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
function wellFormedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
typ: 'bunny-upload',
|
||||
uid: SUBJECT.userId,
|
||||
pid: SUBJECT.projectId,
|
||||
vid: SUBJECT.videoId,
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decodePayload(token: string): Record<string, unknown> {
|
||||
const [encoded] = token.split('.');
|
||||
return JSON.parse(Buffer.from(encoded!, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', SECRET);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('createBunnyUploadToken', () => {
|
||||
it('produces a two-part token separated by a dot', () => {
|
||||
expect(createBunnyUploadToken(SUBJECT).split('.')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('encodes the subject and the issue and expiry times into the payload', () => {
|
||||
expect(decodePayload(createBunnyUploadToken(SUBJECT))).toEqual({
|
||||
typ: 'bunny-upload',
|
||||
uid: 'user-1',
|
||||
pid: 'project-1',
|
||||
vid: 'video-1',
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to a one hour lifetime', () => {
|
||||
const payload = decodePayload(createBunnyUploadToken(SUBJECT));
|
||||
|
||||
expect((payload.exp as number) - (payload.iat as number)).toBe(3600);
|
||||
});
|
||||
|
||||
it('honours an explicit ttl', () => {
|
||||
const payload = decodePayload(createBunnyUploadToken(SUBJECT, 120));
|
||||
|
||||
expect((payload.exp as number) - (payload.iat as number)).toBe(120);
|
||||
});
|
||||
|
||||
it('uses base64url, so the token survives a query string unescaped', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||
expect(encodeURIComponent(token)).toBe(token);
|
||||
});
|
||||
|
||||
it('prefers BUNNY_UPLOAD_TOKEN_SECRET over NEXTAUTH_SECRET', () => {
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
// With only NEXTAUTH_SECRET left, verification must fail, which it can only
|
||||
// do if the dedicated variable was the one that signed.
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to NEXTAUTH_SECRET when the dedicated secret is unset', () => {
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyBunnyUploadToken(createBunnyUploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to mint a token when no secret is configured at all', () => {
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
expect(() => createBunnyUploadToken(SUBJECT)).toThrow(
|
||||
'Missing BUNNY_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBunnyUploadToken', () => {
|
||||
it('accepts a freshly signed token for the subject it was minted for', () => {
|
||||
expect(verifyBunnyUploadToken(createBunnyUploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token whose payload was tampered with', () => {
|
||||
const [encodedPayload, signature] = createBunnyUploadToken(SUBJECT).split('.');
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload!, 'base64url').toString('utf8'));
|
||||
payload.pid = 'project-victim';
|
||||
const forged = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
|
||||
expect(
|
||||
verifyBunnyUploadToken(`${forged}.${signature}`, { ...SUBJECT, projectId: 'project-victim' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token whose signature was tampered with', () => {
|
||||
const [encodedPayload, signature] = createBunnyUploadToken(SUBJECT).split('.');
|
||||
const flipped = (signature![0] === 'A' ? 'B' : 'A') + signature!.slice(1);
|
||||
|
||||
expect(verifyBunnyUploadToken(`${encodedPayload}.${flipped}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token signed under a different secret', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token signed by the R2 grant path, which uses the same algorithm', () => {
|
||||
// Both modules HMAC-SHA256 a base64url payload and both fall back to
|
||||
// NEXTAUTH_SECRET, so the `typ` discriminator is the only thing keeping an
|
||||
// R2 grant from being replayed as a Bunny grant.
|
||||
const token = signArbitrary({
|
||||
typ: 'r2-upload',
|
||||
uid: SUBJECT.userId,
|
||||
pid: SUBJECT.projectId,
|
||||
vid: SUBJECT.videoId,
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
});
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token that has expired', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('still accepts a token in its final second', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 59_000));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a token at the exact expiry second and rejects it one second later', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 60_000));
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(true);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a different user', { userId: 'user-2' }],
|
||||
['a different project', { projectId: 'project-2' }],
|
||||
['a different video', { videoId: 'video-2' }],
|
||||
])('rejects a valid token presented for %s', (_label, override) => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
expect(verifyBunnyUploadToken(token, { ...SUBJECT, ...override })).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['whitespace', ' '],
|
||||
['a single segment', 'notatoken'],
|
||||
['three segments', 'a.b.c'],
|
||||
['a missing signature', 'YWJj.'],
|
||||
['a missing payload', '.c2ln'],
|
||||
['two empty segments', '.'],
|
||||
['a jwt-shaped token', 'eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1c2VyLTEifQ.sig'],
|
||||
['punctuation only', '!!!.???'],
|
||||
])('refuses %s rather than throwing', (_label, token) => {
|
||||
expect(() => verifyBunnyUploadToken(token, SUBJECT)).not.toThrow();
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a signature of the wrong length without letting timingSafeEqual throw', () => {
|
||||
const [encodedPayload] = createBunnyUploadToken(SUBJECT).split('.');
|
||||
|
||||
// crypto.timingSafeEqual throws on unequal buffer lengths, so the length
|
||||
// guard in front of it is load bearing.
|
||||
expect(() => verifyBunnyUploadToken(`${encodedPayload}.short`, SUBJECT)).not.toThrow();
|
||||
expect(verifyBunnyUploadToken(`${encodedPayload}.short`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is not JSON', () => {
|
||||
const encoded = Buffer.from('not json at all', 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', SECRET).update(encoded).digest('base64url');
|
||||
|
||||
expect(verifyBunnyUploadToken(`${encoded}.${signature}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is a JSON scalar rather than an object', () => {
|
||||
expect(verifyBunnyUploadToken(signArbitrary('user-1'), SUBJECT)).toBe(false);
|
||||
expect(verifyBunnyUploadToken(signArbitrary(null), SUBJECT)).toBe(false);
|
||||
expect(verifyBunnyUploadToken(signArbitrary(42), SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['typ'], ['uid'], ['pid'], ['vid'], ['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload missing %s',
|
||||
(field) => {
|
||||
const payload = wellFormedPayload();
|
||||
delete (payload as Record<string, unknown>)[field];
|
||||
|
||||
expect(verifyBunnyUploadToken(signArbitrary(payload), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses a correctly signed payload whose exp is a numeric string', () => {
|
||||
const token = signArbitrary(wellFormedPayload({ exp: String(NOW_SECONDS + ONE_HOUR) }));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload whose iat arrives as null', () => {
|
||||
// Named for what it actually exercises. JSON.stringify writes Infinity as
|
||||
// `null`, so a payload minted from a non-finite number reaches verify() as
|
||||
// null and is rejected one line earlier, by `typeof payload.iat === 'number'`.
|
||||
// The Number.isFinite guard is never consulted on this path; the two tests
|
||||
// below are the ones that reach it.
|
||||
const token = signArbitrary(wellFormedPayload({ iat: Number.POSITIVE_INFINITY }));
|
||||
|
||||
expect(decodePayload(token).iat).toBeNull();
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload whose %s is a JSON literal that overflows to Infinity',
|
||||
(field) => {
|
||||
// The one way a non-finite number survives the wire: `1e999` is legal JSON
|
||||
// and JSON.parse turns it into Infinity, which passes the typeof check and
|
||||
// leaves Number.isFinite as the only thing standing. For exp that matters,
|
||||
// because Infinity < now is false, so without the guard the token would
|
||||
// verify and never expire. Minting one still needs the server secret, so
|
||||
// this is defence in depth rather than a reachable forgery.
|
||||
const json = JSON.stringify(wellFormedPayload()).replace(
|
||||
new RegExp(`"${field}":\\d+`),
|
||||
`"${field}":1e999`
|
||||
);
|
||||
|
||||
expect(JSON.parse(json)[field]).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(verifyBunnyUploadToken(signRawJson(json), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts a correctly signed payload carrying unknown extra fields', () => {
|
||||
// The shape check allowlists the fields it needs rather than rejecting
|
||||
// extras, so a token minted by a newer version still verifies.
|
||||
const token = signArbitrary(wellFormedPayload({ scope: 'tus', v: 2 }));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false rather than throwing when the server has no secret configured', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
// A misconfigured server is indistinguishable from a forged token here.
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getMultipartProgressPercent,
|
||||
getPartByteRange,
|
||||
getRetryDelayMs,
|
||||
getUploadProgressPercent,
|
||||
PART_RETRY_DELAYS_MS,
|
||||
} from '@/lib/client/upload-chunking';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
/** The S3 floor for a non-final part, and the smallest size the host can configure. */
|
||||
const MIN_PART_SIZE = 5 * MIB;
|
||||
/** The default `OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES`. */
|
||||
const DEFAULT_PART_SIZE = 32 * MIB;
|
||||
|
||||
/**
|
||||
* The part list is built by the r2-init route, which sizes it with a ceiling
|
||||
* division over the same file length. Mirroring that here (rather than importing
|
||||
* it) keeps these expectations independent of the module under test.
|
||||
*/
|
||||
function partNumbers(totalBytes: number, partSizeBytes: number): number[] {
|
||||
const count = Math.ceil(totalBytes / partSizeBytes);
|
||||
return Array.from({ length: count }, (_unused, index) => index + 1);
|
||||
}
|
||||
|
||||
function rangesFor(totalBytes: number, partSizeBytes: number) {
|
||||
return partNumbers(totalBytes, partSizeBytes).map((partNumber) =>
|
||||
getPartByteRange(partNumber, partSizeBytes, totalBytes)
|
||||
);
|
||||
}
|
||||
|
||||
describe('PART_RETRY_DELAYS_MS', () => {
|
||||
it('gives a failing part three retries over at most 17 seconds', () => {
|
||||
expect(PART_RETRY_DELAYS_MS).toEqual([0, 2000, 5000, 10000]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRetryDelayMs', () => {
|
||||
it('runs the first attempt immediately', () => {
|
||||
expect(getRetryDelayMs(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('backs off further on each retry', () => {
|
||||
expect(getRetryDelayMs(1)).toBe(2000);
|
||||
expect(getRetryDelayMs(2)).toBe(5000);
|
||||
expect(getRetryDelayMs(3)).toBe(10000);
|
||||
});
|
||||
|
||||
it('reads the delay from a caller-supplied schedule', () => {
|
||||
expect(getRetryDelayMs(1, [0, 50])).toBe(50);
|
||||
expect(getRetryDelayMs(2, [0, 50, 75])).toBe(75);
|
||||
});
|
||||
|
||||
// Guards the `?? 0` fallback: without it the caller would await
|
||||
// setTimeout(undefined), which fires immediately and turns a bounded backoff
|
||||
// into a hot loop.
|
||||
it('waits not at all past the end of the schedule', () => {
|
||||
expect(getRetryDelayMs(4)).toBe(0);
|
||||
expect(getRetryDelayMs(99)).toBe(0);
|
||||
expect(getRetryDelayMs(-1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPartByteRange', () => {
|
||||
it('gives each part a full, non-overlapping slice', () => {
|
||||
expect(getPartByteRange(1, MIN_PART_SIZE, 15 * MIB)).toEqual({ start: 0, end: 5 * MIB });
|
||||
expect(getPartByteRange(2, MIN_PART_SIZE, 15 * MIB)).toEqual({
|
||||
start: 5 * MIB,
|
||||
end: 10 * MIB,
|
||||
});
|
||||
expect(getPartByteRange(3, MIN_PART_SIZE, 15 * MIB)).toEqual({
|
||||
start: 10 * MIB,
|
||||
end: 15 * MIB,
|
||||
});
|
||||
});
|
||||
|
||||
it('splits a file that lands exactly on a part boundary into whole parts', () => {
|
||||
const ranges = rangesFor(15 * MIB, MIN_PART_SIZE);
|
||||
|
||||
expect(ranges).toHaveLength(3);
|
||||
// No short tail: the last part is as long as the others and stops on the
|
||||
// last byte of the file.
|
||||
expect(ranges[2].end - ranges[2].start).toBe(MIN_PART_SIZE);
|
||||
expect(ranges[2].end).toBe(15 * MIB);
|
||||
});
|
||||
|
||||
it('gives one byte over a boundary its own one-byte part', () => {
|
||||
const totalBytes = 15 * MIB + 1;
|
||||
const ranges = rangesFor(totalBytes, MIN_PART_SIZE);
|
||||
|
||||
expect(ranges).toHaveLength(4);
|
||||
expect(ranges[3]).toEqual({ start: 15 * MIB, end: totalBytes });
|
||||
expect(ranges[3].end - ranges[3].start).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the remainder to the final part when the file is one byte short', () => {
|
||||
const totalBytes = 15 * MIB - 1;
|
||||
const ranges = rangesFor(totalBytes, MIN_PART_SIZE);
|
||||
|
||||
expect(ranges).toHaveLength(3);
|
||||
expect(ranges[2]).toEqual({ start: 10 * MIB, end: totalBytes });
|
||||
expect(ranges[2].end - ranges[2].start).toBe(MIN_PART_SIZE - 1);
|
||||
});
|
||||
|
||||
it('covers every byte of the file exactly once, whatever the remainder', () => {
|
||||
for (const totalBytes of [
|
||||
1,
|
||||
MIN_PART_SIZE - 1,
|
||||
MIN_PART_SIZE,
|
||||
MIN_PART_SIZE + 1,
|
||||
3 * MIN_PART_SIZE + 7,
|
||||
DEFAULT_PART_SIZE * 4,
|
||||
DEFAULT_PART_SIZE * 4 + 12345,
|
||||
]) {
|
||||
for (const partSize of [MIN_PART_SIZE, DEFAULT_PART_SIZE]) {
|
||||
const ranges = rangesFor(totalBytes, partSize);
|
||||
expect(ranges[0].start).toBe(0);
|
||||
expect(ranges[ranges.length - 1].end).toBe(totalBytes);
|
||||
for (let index = 1; index < ranges.length; index += 1) {
|
||||
expect(ranges[index].start).toBe(ranges[index - 1].end);
|
||||
}
|
||||
const uploaded = ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
|
||||
expect(uploaded).toBe(totalBytes);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('still lands on the last byte at the 10000-part S3 ceiling', () => {
|
||||
// 10000 parts of 5 MiB is the largest upload the smallest allowed part size
|
||||
// can express, and the offsets there are past 2^35, so this is where an
|
||||
// arithmetic slip would first show up as a truncated or duplicated part.
|
||||
const totalBytes = 10000 * MIN_PART_SIZE;
|
||||
const last = getPartByteRange(10000, MIN_PART_SIZE, totalBytes);
|
||||
|
||||
expect(last.start).toBe(9999 * MIN_PART_SIZE);
|
||||
expect(last.end).toBe(totalBytes);
|
||||
expect(Number.isSafeInteger(last.start)).toBe(true);
|
||||
});
|
||||
|
||||
it('produces an empty range for a zero-byte file', () => {
|
||||
// Unreachable today: r2-init rejects sizeBytes <= 0 before any part is
|
||||
// presigned. Pinned because the arithmetic must not produce a negative
|
||||
// length if that ever changes.
|
||||
expect(getPartByteRange(1, MIN_PART_SIZE, 0)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it('reports a part past the end of the file as an empty slice, not a negative one', () => {
|
||||
// A server that over-counted parts would send part 4 for a 15 MiB file.
|
||||
// `end` below `start` is what Blob.slice reads as empty, so the request goes
|
||||
// out with no bytes rather than with garbage.
|
||||
const range = getPartByteRange(4, MIN_PART_SIZE, 15 * MIB);
|
||||
expect(range.start).toBe(15 * MIB);
|
||||
expect(range.end).toBe(15 * MIB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadProgressPercent', () => {
|
||||
it('reports whole percent through the upload', () => {
|
||||
expect(getUploadProgressPercent(0, 200)).toBe(0);
|
||||
expect(getUploadProgressPercent(50, 200)).toBe(25);
|
||||
expect(getUploadProgressPercent(200, 200)).toBe(100);
|
||||
});
|
||||
|
||||
it('rounds to the nearest percent rather than truncating', () => {
|
||||
expect(getUploadProgressPercent(7, 1000)).toBe(1);
|
||||
expect(getUploadProgressPercent(4, 1000)).toBe(0);
|
||||
expect(getUploadProgressPercent(995, 1000)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMultipartProgressPercent', () => {
|
||||
it('adds up the bytes reported by every part', () => {
|
||||
expect(getMultipartProgressPercent([0, 0, 0], 300)).toBe(0);
|
||||
expect(getMultipartProgressPercent([100, 50, 0], 300)).toBe(50);
|
||||
expect(getMultipartProgressPercent([100, 100, 100], 300)).toBe(100);
|
||||
});
|
||||
|
||||
it('never reports past 100 when a retried part double-counts', () => {
|
||||
// A part that failed halfway and was retried has already reported those
|
||||
// bytes once; without the clamp the bar would run past the end of the track.
|
||||
expect(getMultipartProgressPercent([100, 100, 150], 300)).toBe(100);
|
||||
});
|
||||
|
||||
it('counts progress against the whole file, not the part', () => {
|
||||
expect(getMultipartProgressPercent([100, 0, 0], 300)).toBe(33);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
// Prisma's client errors are real classes, so `err.constructor.name` is what the
|
||||
// sanitiser branches on. Reproducing them as classes rather than as plain objects
|
||||
// with a `name` property is the only way to exercise the branch the way production
|
||||
// reaches it.
|
||||
class PrismaClientKnownRequestError extends Error {
|
||||
code: string;
|
||||
meta?: Record<string, unknown>;
|
||||
constructor(message: string, code: string, meta?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = 'PrismaClientKnownRequestError';
|
||||
this.code = code;
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
class PrismaClientValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PrismaClientValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
class PrismaClientInitializationError extends Error {
|
||||
errorCode: string;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PrismaClientInitializationError';
|
||||
this.errorCode = 'P1001';
|
||||
}
|
||||
}
|
||||
|
||||
// A Stripe SDK error, shaped the way the sanitiser detects it: a string `type`
|
||||
// alongside a numeric `statusCode`.
|
||||
class StripeCardError extends Error {
|
||||
type = 'StripeCardError';
|
||||
statusCode = 402;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StripeCardError';
|
||||
}
|
||||
}
|
||||
|
||||
// The kind of message a Prisma failure actually carries: the failing statement,
|
||||
// the table and column names, and the literal values from the WHERE clause. None
|
||||
// of this may reach a log sink.
|
||||
const LEAKY_PRISMA_MESSAGE = [
|
||||
'Invalid `prisma.user.findUnique()` invocation:',
|
||||
'Raw query failed. Code: `42P01`.',
|
||||
'SELECT "public"."User"."id", "public"."User"."passwordHash" FROM "public"."User"',
|
||||
'WHERE "public"."User"."email" = \'[email protected]\' LIMIT 1 OFFSET 0',
|
||||
].join('\n');
|
||||
|
||||
// Swallows the output as well as capturing it, so the suite stays quiet.
|
||||
function spyOnConsoleError() {
|
||||
return vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
}
|
||||
|
||||
let errorSpy: ReturnType<typeof spyOnConsoleError>;
|
||||
|
||||
function loggedPayload(): unknown {
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
return errorSpy.mock.calls[0]![1];
|
||||
}
|
||||
|
||||
function loggedText(): string {
|
||||
return JSON.stringify(loggedPayload() ?? null);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = spyOnConsoleError();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('logError', () => {
|
||||
describe('Prisma errors', () => {
|
||||
it('redacts a Prisma message carrying raw SQL down to the error code', () => {
|
||||
logError(
|
||||
'user lookup failed',
|
||||
new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002')
|
||||
);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'P2002',
|
||||
message: 'Database error [P2002]',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaks no fragment of the original SQL, table names or WHERE values', () => {
|
||||
logError(
|
||||
'user lookup failed',
|
||||
new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002')
|
||||
);
|
||||
|
||||
const text = loggedText();
|
||||
expect(text).not.toContain('SELECT');
|
||||
expect(text).not.toContain('passwordHash');
|
||||
expect(text).not.toContain('[email protected]');
|
||||
expect(text).not.toContain('prisma.user.findUnique');
|
||||
expect(text).not.toContain('"public"."User"');
|
||||
});
|
||||
|
||||
it('never logs the `meta` object, which repeats the offending field values', () => {
|
||||
const err = new PrismaClientKnownRequestError('Unique constraint failed', 'P2002', {
|
||||
target: ['email'],
|
||||
value: '[email protected]',
|
||||
});
|
||||
|
||||
logError('create failed', err);
|
||||
|
||||
expect(loggedText()).not.toContain('[email protected]');
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'P2002',
|
||||
message: 'Database error [P2002]',
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes UNKNOWN when the Prisma error carries no code', () => {
|
||||
logError('validation failed', new PrismaClientValidationError(LEAKY_PRISMA_MESSAGE));
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'UNKNOWN',
|
||||
message: 'Database error [UNKNOWN]',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts a Prisma initialization error, whose message embeds the database url', () => {
|
||||
const err = new PrismaClientInitializationError(
|
||||
"Can't reach database server at `postgresql://admin:[email protected]:5432`"
|
||||
);
|
||||
|
||||
logError('startup failed', err);
|
||||
|
||||
const text = loggedText();
|
||||
expect(text).not.toContain('hunter2');
|
||||
expect(text).not.toContain('db.internal');
|
||||
// `errorCode`, not `code`, so the string branch does not match it.
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'UNKNOWN',
|
||||
message: 'Database error [UNKNOWN]',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a non-string Prisma code rather than logging it', () => {
|
||||
const err = new PrismaClientKnownRequestError('boom', 'P2002');
|
||||
(err as unknown as Record<string, unknown>).code = 2002;
|
||||
|
||||
logError('create failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'UNKNOWN',
|
||||
message: 'Database error [UNKNOWN]',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the Prisma branch over the Stripe branch when an error matches both', () => {
|
||||
const err = new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002');
|
||||
const anyErr = err as unknown as Record<string, unknown>;
|
||||
anyErr.type = 'invalid_request_error';
|
||||
anyErr.statusCode = 400;
|
||||
|
||||
logError('ambiguous failure', err);
|
||||
|
||||
// If the ordering flipped, `message: err.message` would ship the SQL.
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'P2002',
|
||||
message: 'Database error [P2002]',
|
||||
});
|
||||
});
|
||||
|
||||
// Documents a real limitation rather than an intended behaviour: the branch
|
||||
// keys on the constructor name, so an error that only claims to be a Prisma
|
||||
// error through `err.name` (a re-thrown, deserialised or minified one) falls
|
||||
// through to the generic branch and its message is logged verbatim.
|
||||
it('does not redact an error that is Prisma only by its `name` property', () => {
|
||||
const err = new Error(LEAKY_PRISMA_MESSAGE);
|
||||
err.name = 'PrismaClientKnownRequestError';
|
||||
|
||||
logError('user lookup failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'Error', message: LEAKY_PRISMA_MESSAGE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stripe errors', () => {
|
||||
it('keeps the message and records the http status as the code', () => {
|
||||
logError('charge failed', new StripeCardError('Your card was declined.'));
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'StripeCardError',
|
||||
code: '402',
|
||||
message: 'Your card was declined.',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the SDK `type` field rather than the class name', () => {
|
||||
const err = new StripeCardError('No such customer: cus_123');
|
||||
(err as unknown as Record<string, unknown>).type = 'invalid_request_error';
|
||||
|
||||
logError('portal failed', err);
|
||||
|
||||
expect(loggedPayload()).toMatchObject({ type: 'invalid_request_error', code: '402' });
|
||||
});
|
||||
|
||||
it('falls through to the generic branch when statusCode is not numeric', () => {
|
||||
const err = new StripeCardError('Your card was declined.');
|
||||
(err as unknown as Record<string, unknown>).statusCode = '402';
|
||||
|
||||
logError('charge failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'StripeCardError',
|
||||
message: 'Your card was declined.',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls through to the generic branch when `type` is not a string', () => {
|
||||
const err = new StripeCardError('Your card was declined.');
|
||||
(err as unknown as Record<string, unknown>).type = 7;
|
||||
|
||||
logError('charge failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'StripeCardError',
|
||||
message: 'Your card was declined.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain errors', () => {
|
||||
it('logs the type and message of an ordinary Error', () => {
|
||||
logError('something broke', new Error('boom'));
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'Error', message: 'boom' });
|
||||
});
|
||||
|
||||
it('reports the subclass name as the type', () => {
|
||||
class UploadRejectedError extends Error {}
|
||||
|
||||
logError('upload failed', new UploadRejectedError('too large'));
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'UploadRejectedError', message: 'too large' });
|
||||
});
|
||||
|
||||
it('never includes the stack trace, which exposes absolute server paths', () => {
|
||||
const err = new Error('boom');
|
||||
err.stack = 'Error: boom\n at /srv/openframe/app/api/projects/route.ts:42:11';
|
||||
|
||||
logError('something broke', err);
|
||||
|
||||
expect(loggedPayload()).not.toHaveProperty('stack');
|
||||
expect(loggedText()).not.toContain('/srv/openframe');
|
||||
});
|
||||
|
||||
it('does not include a `cause`, which can wrap the original driver error', () => {
|
||||
const err = new Error('wrapped', { cause: new Error(LEAKY_PRISMA_MESSAGE) });
|
||||
|
||||
logError('something broke', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'Error', message: 'wrapped' });
|
||||
expect(loggedText()).not.toContain('SELECT');
|
||||
});
|
||||
|
||||
it('handles a TypeError thrown by the runtime itself', () => {
|
||||
logError('bad access', new TypeError("Cannot read properties of undefined (reading 'id')"));
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'TypeError',
|
||||
message: "Cannot read properties of undefined (reading 'id')",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-Error values', () => {
|
||||
// These were constructed by the caller, so they are already whatever the
|
||||
// caller decided to expose and are passed through untouched.
|
||||
it.each([
|
||||
['a string', 'plain failure text'],
|
||||
['a number', 42],
|
||||
['a boolean', false],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('passes %s through unchanged', (_label, value) => {
|
||||
logError('context', value);
|
||||
|
||||
expect(loggedPayload()).toBe(value);
|
||||
});
|
||||
|
||||
it('passes a structured object through by reference', () => {
|
||||
const payload = { status: 502, provider: 'bunny' };
|
||||
|
||||
logError('upstream refused', payload);
|
||||
|
||||
expect(loggedPayload()).toBe(payload);
|
||||
});
|
||||
|
||||
it('passes an Error-shaped plain object through, since it is not an Error instance', () => {
|
||||
const payload = { name: 'PrismaClientKnownRequestError', message: LEAKY_PRISMA_MESSAGE };
|
||||
|
||||
logError('context', payload);
|
||||
|
||||
expect(loggedPayload()).toBe(payload);
|
||||
});
|
||||
});
|
||||
|
||||
it('writes to console.error with the context string first and the payload second', () => {
|
||||
logError('projects.POST failed', new Error('boom'));
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy.mock.calls[0]).toHaveLength(2);
|
||||
expect(errorSpy.mock.calls[0]![0]).toBe('projects.POST failed');
|
||||
});
|
||||
|
||||
it('returns undefined rather than the sanitized payload', () => {
|
||||
expect(logError('context', new Error('boom'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
// lib/notifications.ts is stubbed wholesale in tests/setup/api.ts so the API
|
||||
// suite never fans out to Telegram or SMTP. This file stubs the boundaries
|
||||
// instead (Prisma, global fetch, nodemailer) and asserts on the decision the
|
||||
// module actually owns: who receives a notification and who does not.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type NotificationEvent, notifyProjectOwner, notifyUsers } from '@/lib/notifications';
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
notificationSetting: { findMany: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
|
||||
|
||||
const mail = vi.hoisted(() => {
|
||||
const sendMail = vi.fn<
|
||||
(message: {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}) => Promise<{ messageId: string }>
|
||||
>(async () => ({ messageId: 'unit-test-message-id' }));
|
||||
const createTransport = vi.fn(() => ({ sendMail, verify: vi.fn(async () => true) }));
|
||||
return { sendMail, createTransport };
|
||||
});
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: { createTransport: mail.createTransport },
|
||||
createTransport: mail.createTransport,
|
||||
}));
|
||||
|
||||
type Settings = Parameters<typeof settingsRow>[0];
|
||||
|
||||
function settingsRow(overrides: {
|
||||
userId?: string;
|
||||
email?: string | null;
|
||||
emailEnabled?: boolean;
|
||||
telegramEnabled?: boolean;
|
||||
telegramChatId?: string | null;
|
||||
timezone?: string;
|
||||
onNewVideo?: boolean;
|
||||
onNewVersion?: boolean;
|
||||
onNewComment?: boolean;
|
||||
onNewReply?: boolean;
|
||||
onApprovalEvents?: boolean;
|
||||
}) {
|
||||
const userId = overrides.userId ?? 'user-1';
|
||||
return {
|
||||
userId,
|
||||
emailEnabled: overrides.emailEnabled ?? true,
|
||||
telegramEnabled: overrides.telegramEnabled ?? true,
|
||||
telegramChatId: overrides.telegramChatId === undefined ? 'chat-1' : overrides.telegramChatId,
|
||||
timezone: overrides.timezone ?? 'UTC',
|
||||
onNewVideo: overrides.onNewVideo ?? true,
|
||||
onNewVersion: overrides.onNewVersion ?? true,
|
||||
onNewComment: overrides.onNewComment ?? true,
|
||||
onNewReply: overrides.onNewReply ?? true,
|
||||
onApprovalEvents: overrides.onApprovalEvents ?? true,
|
||||
user: { email: overrides.email === undefined ? `${userId}@example.com` : overrides.email },
|
||||
};
|
||||
}
|
||||
|
||||
function recipients(...rows: ReturnType<typeof settingsRow>[]): void {
|
||||
dbMock.notificationSetting.findMany.mockResolvedValue(rows);
|
||||
}
|
||||
|
||||
const COMMENT_EVENT: NotificationEvent = {
|
||||
type: 'new_comment',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
commentAuthor: 'Ada',
|
||||
commentText: 'The cut at 0:12 is too fast',
|
||||
timestamp: '0:12',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
};
|
||||
|
||||
const VIDEO_EVENT: NotificationEvent = {
|
||||
type: 'new_video',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
addedBy: 'Ada',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
};
|
||||
|
||||
const EVENTS: Record<string, NotificationEvent> = {
|
||||
new_video: VIDEO_EVENT,
|
||||
new_version: {
|
||||
type: 'new_version',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
addedBy: 'Ada',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
new_comment: COMMENT_EVENT,
|
||||
new_reply: {
|
||||
type: 'new_reply',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
replyAuthor: 'Grace',
|
||||
replyText: 'Agreed',
|
||||
parentAuthor: 'Ada',
|
||||
timestamp: '0:12',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_requested: {
|
||||
type: 'approval_requested',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
requestedBy: 'Ada',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_action: {
|
||||
type: 'approval_action',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
actorName: 'Grace',
|
||||
action: 'approved',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_completed: {
|
||||
type: 'approval_completed',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
approvedByCount: 2,
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_rejected: {
|
||||
type: 'approval_rejected',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
rejectedBy: 'Grace',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
/** The parsed body of the Nth Telegram call. */
|
||||
function telegramPayload(index = 0): Record<string, unknown> {
|
||||
return JSON.parse(String(fetchMock.mock.calls[index][1].body));
|
||||
}
|
||||
|
||||
function sentMail(index = 0): { from: string; to: string; subject: string; html: string } {
|
||||
return mail.sendMail.mock.calls[index][0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.notificationSetting.findMany.mockReset();
|
||||
dbMock.notificationSetting.findMany.mockResolvedValue([]);
|
||||
mail.sendMail.mockReset();
|
||||
mail.sendMail.mockResolvedValue({ messageId: 'unit-test-message-id' });
|
||||
mail.createTransport.mockClear();
|
||||
|
||||
fetchMock = vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
vi.stubEnv('TELEGRAM_BOT_TOKEN', 'bot-token-unit');
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.com');
|
||||
vi.stubEnv('SMTP_PORT', '587');
|
||||
vi.stubEnv('SMTP_USER', 'smtp-user');
|
||||
vi.stubEnv('SMTP_PASSWORD', 'smtp-password');
|
||||
vi.stubEnv('SMTP_FROM', undefined);
|
||||
vi.stubEnv('EMAIL_FROM', undefined);
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('choosing the recipient list', () => {
|
||||
it('does not query the database when no recipient was named', async () => {
|
||||
await notifyUsers([], COMMENT_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not query the database when every recipient id is empty', async () => {
|
||||
await notifyUsers(['', ''], COMMENT_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates the recipient list and drops empty ids before querying', async () => {
|
||||
await notifyUsers(['user-1', 'user-1', '', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: { in: ['user-1', 'user-2'] } },
|
||||
include: { user: { select: { email: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
// A user with no settings row is simply absent from findMany's result, so the
|
||||
// fan-out silently skips them. That is the current contract.
|
||||
it('sends nothing to a named user who has no notification settings row', async () => {
|
||||
recipients();
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reaches every recipient that does have a row', async () => {
|
||||
recipients(settingsRow({ userId: 'user-1' }), settingsRow({ userId: 'user-2' }));
|
||||
|
||||
await notifyUsers(['user-1', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(sentMail(0).to).toBe('[email protected]');
|
||||
expect(sentMail(1).to).toBe('[email protected]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-user event settings', () => {
|
||||
it.each([
|
||||
['new_video', 'onNewVideo'],
|
||||
['new_version', 'onNewVersion'],
|
||||
['new_comment', 'onNewComment'],
|
||||
['new_reply', 'onNewReply'],
|
||||
['approval_requested', 'onApprovalEvents'],
|
||||
['approval_action', 'onApprovalEvents'],
|
||||
['approval_completed', 'onApprovalEvents'],
|
||||
['approval_rejected', 'onApprovalEvents'],
|
||||
] as const)('sends a %s event only when %s is on', async (eventType, flag) => {
|
||||
recipients(settingsRow({ [flag]: false } as Settings));
|
||||
await notifyUsers(['user-1'], EVENTS[eventType]);
|
||||
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
recipients(settingsRow({ [flag]: true } as Settings));
|
||||
await notifyUsers(['user-1'], EVENTS[eventType]);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('turning off one event type leaves the others alone', async () => {
|
||||
recipients(settingsRow({ onNewComment: false }));
|
||||
|
||||
await notifyUsers(['user-1'], VIDEO_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends nothing for an event type the settings do not map', async () => {
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], { type: 'video_deleted' } as unknown as NotificationEvent);
|
||||
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel selection', () => {
|
||||
it('still sends the email to a user who has no Telegram chat id', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: true, telegramChatId: null }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(sentMail(0).to).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('still sends the email when the deployment has no Telegram bot token', async () => {
|
||||
vi.stubEnv('TELEGRAM_BOT_TOKEN', undefined);
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips Telegram for a user who turned it off', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips email for a user who turned it off but still sends Telegram', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips email for a user with no address on file', async () => {
|
||||
recipients(settingsRow({ email: null }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends nothing at all to a user with both channels off', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false, telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not build an SMTP transport when SMTP is unconfigured', async () => {
|
||||
vi.stubEnv('SMTP_HOST', undefined);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.createTransport).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses an implicit TLS connection only on port 465', async () => {
|
||||
vi.stubEnv('SMTP_PORT', '465');
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: 'smtp.example.com', port: 465, secure: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one failing recipient does not stop the rest', () => {
|
||||
it('keeps delivering after a recipient whose email send rejects', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mail.sendMail.mockImplementation(async (message) => {
|
||||
if (message.to === '[email protected]') throw new Error('mailbox full');
|
||||
return { messageId: 'ok' };
|
||||
});
|
||||
recipients(settingsRow({ userId: 'user-1' }), settingsRow({ userId: 'user-2' }));
|
||||
|
||||
await notifyUsers(['user-1', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(sentMail(1).to).toBe('[email protected]');
|
||||
expect(logged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps delivering after a recipient whose Telegram call rejects', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
fetchMock.mockRejectedValueOnce(new Error('telegram unreachable'));
|
||||
recipients(settingsRow({ userId: 'user-1' }), settingsRow({ userId: 'user-2' }));
|
||||
|
||||
await notifyUsers(['user-1', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(logged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still emails a user whose own Telegram delivery failed', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 403, text: async () => 'bot blocked' });
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves rather than throwing when the settings lookup fails', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
dbMock.notificationSetting.findMany.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
await expect(notifyUsers(['user-1'], COMMENT_EVENT)).resolves.toBeUndefined();
|
||||
expect(logged).toHaveBeenCalledWith('Notification dispatch failed:', {
|
||||
type: 'Error',
|
||||
message: 'connection refused',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves rather than throwing when a recipient has a malformed settings row', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// `user` is missing, so reading settings.user.email throws inside the map.
|
||||
dbMock.notificationSetting.findMany.mockResolvedValue([
|
||||
{ ...settingsRow({}), user: undefined },
|
||||
settingsRow({ userId: 'user-2' }),
|
||||
]);
|
||||
|
||||
await expect(notifyUsers(['user-1', 'user-2'], COMMENT_EVENT)).resolves.toBeUndefined();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(sentMail(0).to).toBe('[email protected]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Telegram message', () => {
|
||||
it('posts to the bot sendMessage endpoint with the chat id and preview disabled', async () => {
|
||||
recipients(settingsRow({ telegramChatId: 'chat-42', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'https://api.telegram.org/botbot-token-unit/sendMessage'
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(telegramPayload()).toMatchObject({
|
||||
chat_id: 'chat-42',
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the deep link as an inline button', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(telegramPayload().reply_markup).toEqual({
|
||||
inline_keyboard: [[{ text: 'View Comment', url: 'https://app.example.com/watch/video-1' }]],
|
||||
});
|
||||
});
|
||||
|
||||
// Telegram rejects an inline keyboard whose url is not https, which would
|
||||
// fail the whole message rather than just the button.
|
||||
it('omits the button when the deep link is not https', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, url: 'http://localhost:3000/watch/video-1' });
|
||||
|
||||
expect(telegramPayload().reply_markup).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries the project, video, author and comment body in the text', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Project: Launch Film');
|
||||
expect(text).toContain('Video: Teaser');
|
||||
expect(text).toContain('By: Ada at 0:12');
|
||||
expect(text).toContain('"The cut at 0:12 is too fast"');
|
||||
// The url lives on the button, not in the body.
|
||||
expect(text).not.toContain('https://app.example.com');
|
||||
});
|
||||
|
||||
it('truncates a long comment body to 200 characters', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, commentText: 'x'.repeat(250) });
|
||||
|
||||
expect(String(telegramPayload().text)).toContain(`"${'x'.repeat(200)}..."`);
|
||||
});
|
||||
|
||||
it('leaves a body at exactly 200 characters untruncated', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, commentText: 'x'.repeat(200) });
|
||||
|
||||
expect(String(telegramPayload().text)).toContain(`"${'x'.repeat(200)}"`);
|
||||
});
|
||||
|
||||
it('omits the optional note block when an approval carries no note', async () => {
|
||||
// Rendered twice, once without a note and once with, so the assertion is
|
||||
// about the note block itself rather than about quotation marks in general:
|
||||
// the two bodies have to differ by exactly that block and nothing else. The
|
||||
// clock is frozen because the body carries a minute-precision timestamp, and
|
||||
// a rollover between the two calls would make them differ for an unrelated
|
||||
// reason.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z'));
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
// Spelled out rather than taken from EVENTS so that `note` can be added to a
|
||||
// copy: EVENTS is typed as the whole NotificationEvent union, and only the
|
||||
// approval variants carry a note.
|
||||
const rejected = {
|
||||
type: 'approval_rejected',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
rejectedBy: 'Grace',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
} satisfies NotificationEvent;
|
||||
|
||||
await notifyUsers(['user-1'], rejected);
|
||||
await notifyUsers(['user-1'], { ...rejected, note: 'colour is off' });
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(String(telegramPayload(1).text)).toBe(
|
||||
`${String(telegramPayload(0).text)}\n\n"colour is off"`
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the note when an approval carries one', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
const withNote: NotificationEvent = {
|
||||
type: 'approval_rejected',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
rejectedBy: 'Grace',
|
||||
note: 'colour is off',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
};
|
||||
|
||||
await notifyUsers(['user-1'], withNote);
|
||||
|
||||
expect(String(telegramPayload().text)).toContain('"colour is off"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the email message', () => {
|
||||
it.each([
|
||||
['new_video', '[OpenFrame] New video in Launch Film: Teaser'],
|
||||
['new_version', '[OpenFrame] New version of Teaser in Launch Film'],
|
||||
['new_comment', '[OpenFrame] New comment on Teaser'],
|
||||
['new_reply', '[OpenFrame] Grace replied on Teaser'],
|
||||
['approval_requested', '[OpenFrame] Approval requested for v2 in Launch Film'],
|
||||
['approval_action', '[OpenFrame] Approval approved by Grace'],
|
||||
['approval_completed', '[OpenFrame] Approval completed for v2'],
|
||||
['approval_rejected', '[OpenFrame] Approval rejected by Grace'],
|
||||
])('subjects a %s event as %s', async (eventType, subject) => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], EVENTS[eventType]);
|
||||
|
||||
expect(sentMail(0).subject).toBe(subject);
|
||||
});
|
||||
|
||||
it('falls back to the product address when no from address is configured', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(sentMail(0).from).toBe('OpenFrame <[email protected]>');
|
||||
});
|
||||
|
||||
it('prefers SMTP_FROM over EMAIL_FROM', async () => {
|
||||
vi.stubEnv('SMTP_FROM', 'A <[email protected]>');
|
||||
vi.stubEnv('EMAIL_FROM', 'B <[email protected]>');
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(sentMail(0).from).toBe('A <[email protected]>');
|
||||
});
|
||||
|
||||
it('escapes user-supplied text so a project name cannot inject markup', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], {
|
||||
...COMMENT_EVENT,
|
||||
projectName: '<script>alert(1)</script>',
|
||||
commentText: '<img src=x onerror=alert(1)>',
|
||||
});
|
||||
|
||||
const { html } = sentMail(0);
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('links the footer at the configured app url', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(sentMail(0).html).toContain('https://app.example.com/settings');
|
||||
});
|
||||
|
||||
it('truncates a long comment body to 300 characters', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, commentText: 'x'.repeat(400) });
|
||||
|
||||
expect(sentMail(0).html).toContain(`${'x'.repeat(300)}...`);
|
||||
expect(sentMail(0).html).not.toContain('x'.repeat(301));
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestamp rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T23:30:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the time in the recipient timezone', async () => {
|
||||
recipients(settingsRow({ timezone: 'Europe/Istanbul', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
// 23:30 UTC is 02:30 the next day in Istanbul (UTC+3). The date and the
|
||||
// time are asserted separately because the separator between them is an
|
||||
// ICU detail that differs between runtimes.
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Jan 16, 2026');
|
||||
expect(text).toContain('2:30 AM');
|
||||
});
|
||||
|
||||
it('falls back to UTC for a timezone the runtime rejects', async () => {
|
||||
recipients(settingsRow({ timezone: 'Mars/Olympus', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Jan 15, 2026');
|
||||
expect(text).toContain('11:30 PM');
|
||||
});
|
||||
|
||||
it('falls back to UTC when the row stores an empty timezone', async () => {
|
||||
recipients(settingsRow({ timezone: '', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Jan 15, 2026');
|
||||
expect(text).toContain('11:30 PM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyProjectOwner', () => {
|
||||
it('fans out to the single owner id', async () => {
|
||||
recipients(settingsRow({ userId: 'owner-1' }));
|
||||
|
||||
await notifyProjectOwner('owner-1', VIDEO_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: { in: ['owner-1'] } },
|
||||
include: { user: { select: { email: true } } },
|
||||
});
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when the owner id is empty', async () => {
|
||||
await notifyProjectOwner('', VIDEO_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,496 @@
|
||||
// Unit tests for lib/r2-media-proxy.ts, the single helper behind every media
|
||||
// proxy route (`/api/upload/image/[filename]`, `/api/upload/audio/[filename]`,
|
||||
// `/api/upload/video/[filename]`).
|
||||
//
|
||||
// The routes decide *who* may read an object; this module decides *what* comes
|
||||
// back. Everything interesting it does is invisible from the route tests, which
|
||||
// stub this function out at its boundary: the Range and If-Range plumbing, the
|
||||
// content-type fallback, the 404/416/500 mapping of S3 errors, and the header
|
||||
// set. All of it is exercised here against a fake `r2Client`, so no test in this
|
||||
// file speaks S3.
|
||||
//
|
||||
// The seam is `@/lib/r2`. Mocking it rather than the AWS SDK keeps the real
|
||||
// GetObjectCommand in play, which is what lets the assertions below read the
|
||||
// exact command input the module built.
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/r2', () => ({
|
||||
r2Client: { send: sendMock },
|
||||
R2_BUCKET_NAME: 'test-bucket',
|
||||
}));
|
||||
|
||||
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
|
||||
|
||||
const BASE_OPTIONS = {
|
||||
key: 'images/photo.png',
|
||||
fallbackContentType: 'image/png',
|
||||
cacheControl: 'private, no-store',
|
||||
internalErrorMessage: 'Failed to retrieve image',
|
||||
};
|
||||
|
||||
/** A GetObjectCommandOutput carrying `text` as a Node stream, the shape the SDK returns. */
|
||||
function objectWith(overrides: Record<string, unknown> = {}, text = 'file-bytes') {
|
||||
return {
|
||||
Body: Readable.from([Buffer.from(text)]),
|
||||
ContentType: 'image/png',
|
||||
ContentLength: text.length,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** An error shaped like the ones @aws-sdk/client-s3 throws. */
|
||||
function s3Error(props: { name?: string; Code?: string; httpStatusCode?: number }): Error {
|
||||
const error = new Error('s3 failure');
|
||||
if (props.name) error.name = props.name;
|
||||
return Object.assign(error, {
|
||||
Code: props.Code,
|
||||
$metadata: { httpStatusCode: props.httpStatusCode },
|
||||
});
|
||||
}
|
||||
|
||||
/** The input of the nth GetObjectCommand handed to r2Client.send(). */
|
||||
function commandInput(call = 0): Record<string, unknown> {
|
||||
const command = sendMock.mock.calls[call]?.[0] as GetObjectCommand;
|
||||
return command.input as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function request(headers: Record<string, string> = {}): Request {
|
||||
return new Request('http://localhost:3000/api/upload/image/photo.png', { headers });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset();
|
||||
// logError() writes to console.error on the failure paths. Silenced so the
|
||||
// expected-error tests do not print, and so the last case can assert on it.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The object key
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('key handling', () => {
|
||||
it('sends the caller-supplied key and the configured bucket verbatim', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(commandInput()).toMatchObject({ Bucket: 'test-bucket', Key: 'images/photo.png' });
|
||||
});
|
||||
|
||||
// Pinning the absence of validation, not endorsing it. This module applies no
|
||||
// normalisation and no prefix check to `key`, so a caller that builds one from
|
||||
// unvalidated input hands the traversal straight to S3. Today all three call
|
||||
// sites gate the filename on a UUID regex first, which is the only reason this
|
||||
// is not reachable. If a fourth route ever skips that regex, nothing in this
|
||||
// module will stop it. See the report accompanying this suite.
|
||||
it('passes a traversal-shaped key through untouched', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
key: 'images/../../etc/passwd',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(commandInput().Key).toBe('images/../../etc/passwd');
|
||||
});
|
||||
|
||||
it('sends no Range or conditional fields when the request has no range header', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
const input = commandInput();
|
||||
expect(input.Range).toBeUndefined();
|
||||
expect(input.IfMatch).toBeUndefined();
|
||||
expect(input.IfUnmodifiedSince).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The success response
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('a successful read', () => {
|
||||
it('returns 200 with the object bytes', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({}, 'hello-media'));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe('hello-media');
|
||||
});
|
||||
|
||||
it('prefers the content type R2 reports over the fallback', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentType: 'image/webp' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
fallbackContentType: 'image/png',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('image/webp');
|
||||
});
|
||||
|
||||
// R2 stores objects uploaded without an explicit type as
|
||||
// application/octet-stream. Serving that back would make the browser download
|
||||
// the file instead of rendering it, so the route's extension-derived guess wins.
|
||||
it('falls back to the caller content type when R2 reports application/octet-stream', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentType: 'application/octet-stream' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
fallbackContentType: 'audio/webm',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('audio/webm');
|
||||
});
|
||||
|
||||
it('falls back to the caller content type when R2 reports none at all', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentType: undefined }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
fallbackContentType: 'video/mp4',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('passes through length, etag and last-modified from the object', async () => {
|
||||
sendMock.mockResolvedValue(
|
||||
objectWith({
|
||||
ContentLength: 9,
|
||||
ETag: '"abc123"',
|
||||
LastModified: new Date(Date.UTC(2026, 0, 2, 3, 4, 5)),
|
||||
})
|
||||
);
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.headers.get('content-length')).toBe('9');
|
||||
expect(response.headers.get('etag')).toBe('"abc123"');
|
||||
expect(response.headers.get('last-modified')).toBe('Fri, 02 Jan 2026 03:04:05 GMT');
|
||||
});
|
||||
|
||||
it('omits headers R2 did not report rather than sending empty ones', async () => {
|
||||
sendMock.mockResolvedValue(
|
||||
objectWith({ ContentLength: undefined, ETag: undefined, LastModified: undefined })
|
||||
);
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.headers.has('etag')).toBe(false);
|
||||
expect(response.headers.has('last-modified')).toBe(false);
|
||||
});
|
||||
|
||||
// nosniff and `inline` are what keep a stored .png that is really HTML from
|
||||
// being rendered as a document in the user's origin.
|
||||
it('always sets nosniff, inline disposition and the caller cache policy', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
cacheControl: 'private, max-age=3600',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(response.headers.get('content-disposition')).toBe('inline');
|
||||
expect(response.headers.get('cache-control')).toBe('private, max-age=3600');
|
||||
expect(response.headers.get('accept-ranges')).toBe('bytes');
|
||||
});
|
||||
|
||||
it('applies extraHeaders on top, overriding what the module set', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
extraHeaders: {
|
||||
'Content-Security-Policy': "default-src 'none'; sandbox",
|
||||
'Content-Disposition': 'attachment',
|
||||
},
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox");
|
||||
expect(response.headers.get('content-disposition')).toBe('attachment');
|
||||
});
|
||||
|
||||
it('accepts a web ReadableStream body as well as a Node stream', async () => {
|
||||
sendMock.mockResolvedValue({
|
||||
ContentType: 'image/png',
|
||||
Body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('web-stream-bytes'));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe('web-stream-bytes');
|
||||
});
|
||||
|
||||
it('returns 500 when the object came back with no body to stream', async () => {
|
||||
sendMock.mockResolvedValue({ ContentType: 'image/png', Body: undefined });
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'Empty file' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range requests
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video scrubbing depends entirely on this path: the browser asks for a byte
|
||||
// window and expects 206 plus a Content-Range back. A regression that dropped
|
||||
// the range and answered 200 with the whole file would still "work" in a
|
||||
// download and break seeking.
|
||||
describe('range requests', () => {
|
||||
it('forwards the range header and answers 206 when R2 returns a partial object', async () => {
|
||||
sendMock.mockResolvedValue(
|
||||
objectWith({ ContentRange: 'bytes 0-4/100', ContentLength: 5 }, 'first')
|
||||
);
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4' }),
|
||||
});
|
||||
|
||||
expect(commandInput().Range).toBe('bytes=0-4');
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get('content-range')).toBe('bytes 0-4/100');
|
||||
await expect(response.text()).resolves.toBe('first');
|
||||
});
|
||||
|
||||
it('answers 200 when a range was asked for but R2 returned the whole object', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: undefined }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('turns an unsatisfiable range into an empty 416', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ name: 'InvalidRange' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
cacheControl: 'private, max-age=3600',
|
||||
request: request({ range: 'bytes=99999-' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(416);
|
||||
expect(response.headers.get('accept-ranges')).toBe('bytes');
|
||||
expect(response.headers.get('cache-control')).toBe('private, max-age=3600');
|
||||
await expect(response.text()).resolves.toBe('');
|
||||
});
|
||||
|
||||
it('recognises an unsatisfiable range reported only as HTTP 416', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ httpStatusCode: 416 }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=99999-' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(416);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// If-Range
|
||||
// ---------------------------------------------------------------------------
|
||||
// If-Range asks "give me this window, but only if the file has not changed since
|
||||
// I started". S3 has no If-Range, so the module translates it into IfMatch or
|
||||
// IfUnmodifiedSince and handles the 412 itself.
|
||||
describe('if-range handling', () => {
|
||||
it('translates a strong etag into IfMatch', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: 'bytes 0-4/100' }));
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': '"abc123"' }),
|
||||
});
|
||||
|
||||
expect(commandInput().IfMatch).toBe('"abc123"');
|
||||
expect(commandInput().IfUnmodifiedSince).toBeUndefined();
|
||||
});
|
||||
|
||||
it('translates an HTTP date into IfUnmodifiedSince', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: 'bytes 0-4/100' }));
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': 'Fri, 02 Jan 2026 03:04:05 GMT' }),
|
||||
});
|
||||
|
||||
expect(commandInput().IfMatch).toBeUndefined();
|
||||
expect((commandInput().IfUnmodifiedSince as Date).toUTCString()).toBe(
|
||||
'Fri, 02 Jan 2026 03:04:05 GMT'
|
||||
);
|
||||
});
|
||||
|
||||
// A weak validator (W/"...") cannot be used for byte-range equivalence, and
|
||||
// the token is not a date either, so neither condition is attachable.
|
||||
it('ignores a weak etag rather than sending it as IfMatch', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: 'bytes 0-4/100' }));
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': 'W/"abc123"' }),
|
||||
});
|
||||
|
||||
expect(commandInput().IfMatch).toBeUndefined();
|
||||
expect(commandInput().IfUnmodifiedSince).toBeUndefined();
|
||||
expect(commandInput().Range).toBe('bytes=0-4');
|
||||
});
|
||||
|
||||
it('ignores if-range entirely when the request carries no range', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request({ 'if-range': '"abc123"' }) });
|
||||
|
||||
expect(commandInput().IfMatch).toBeUndefined();
|
||||
});
|
||||
|
||||
// The whole point of If-Range: when the validator no longer matches, the client
|
||||
// wants the full object back, not an error. S3 answers 412; the module retries
|
||||
// without the range and returns 200.
|
||||
it('retries without the range and returns the full object on a 412', async () => {
|
||||
sendMock
|
||||
.mockRejectedValueOnce(s3Error({ httpStatusCode: 412 }))
|
||||
.mockResolvedValueOnce(objectWith({ ContentRange: undefined }, 'whole-file'));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': '"stale-etag"' }),
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(commandInput(1).Range).toBeUndefined();
|
||||
expect(commandInput(1).IfMatch).toBeUndefined();
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe('whole-file');
|
||||
});
|
||||
|
||||
// Without a conditional attached there is nothing to fall back to, so a 412
|
||||
// is just an error like any other.
|
||||
it('does not retry a 412 that arrived without an if-range', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ httpStatusCode: 412 }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4' }),
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('reports the object as gone when the retry after a 412 finds nothing', async () => {
|
||||
sendMock
|
||||
.mockRejectedValueOnce(s3Error({ httpStatusCode: 412 }))
|
||||
.mockRejectedValueOnce(s3Error({ name: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
notFoundLabel: 'Audio',
|
||||
request: request({ range: 'bytes=0-4', 'if-range': '"stale-etag"' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'Audio not found' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Missing objects and failures
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('a missing object', () => {
|
||||
it('returns 404 labelled with the caller resource name', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ name: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
notFoundLabel: 'Image',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'Image not found' });
|
||||
});
|
||||
|
||||
it('defaults the label to File when the caller gave none', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ name: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'File not found' });
|
||||
});
|
||||
|
||||
// Some S3-compatible backends report the condition as a `Code` field or as a
|
||||
// bare 404 rather than through the error name.
|
||||
it('recognises NoSuchKey reported as a Code field', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ Code: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('recognises a missing object reported only as HTTP 404', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ httpStatusCode: 404 }));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unexpected storage failure', () => {
|
||||
it('returns 500 with the caller message and never the underlying error', async () => {
|
||||
sendMock.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.1:9000'));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
internalErrorMessage: 'Failed to load video',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = (await response.json()) as { error: string };
|
||||
expect(body.error).toBe('Failed to load video');
|
||||
expect(body.error).not.toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('logs the failure through logError so it reaches the sanitising sink', async () => {
|
||||
sendMock.mockRejectedValue(new Error('bucket exploded'));
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('Error proxying R2 object:', {
|
||||
type: 'Error',
|
||||
message: 'bucket exploded',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
createR2UploadToken,
|
||||
parseR2UploadToken,
|
||||
verifyR2UploadToken,
|
||||
type R2UploadTokenSubject,
|
||||
} from '@/lib/r2-upload-token';
|
||||
|
||||
const SECRET = 'r2-upload-token-test-secret';
|
||||
const OTHER_SECRET = 'a-completely-different-secret';
|
||||
const NOW = new Date('2026-01-15T12:00:00.000Z');
|
||||
const NOW_SECONDS = Math.floor(NOW.getTime() / 1000);
|
||||
const ONE_HOUR = 60 * 60;
|
||||
|
||||
const SUBJECT = {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
objectKey: 'projects/project-1/videos/video-1/source.mp4',
|
||||
sessionId: 'session-1',
|
||||
tokenId: 'token-1',
|
||||
thumbnailObjectKey: 'projects/project-1/videos/video-1/thumb.jpg',
|
||||
} satisfies R2UploadTokenSubject & {
|
||||
sessionId: string;
|
||||
tokenId: string;
|
||||
thumbnailObjectKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mints a token over an arbitrary payload with a valid signature. Signatures are
|
||||
* never hardcoded here: they depend on the secret, so every expectation is about
|
||||
* behaviour. This exists only to reach the payload-shape checks, which a forged
|
||||
* signature can never get past.
|
||||
*/
|
||||
function signArbitrary(payload: unknown, secret = SECRET): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs raw JSON text rather than an object. JSON.stringify cannot emit a
|
||||
* non-finite number, so this is the only way to hand verify() a payload whose
|
||||
* `iat` or `exp` parses back as Infinity: a decimal exponent that overflows to
|
||||
* it, which JSON.parse accepts and turns into Infinity.
|
||||
*/
|
||||
function signRawJson(json: string, secret = SECRET): string {
|
||||
const encoded = Buffer.from(json, 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
function wellFormedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
typ: 'r2-upload',
|
||||
uid: SUBJECT.userId,
|
||||
pid: SUBJECT.projectId,
|
||||
key: SUBJECT.objectKey,
|
||||
sid: SUBJECT.sessionId,
|
||||
jti: SUBJECT.tokenId,
|
||||
tkey: SUBJECT.thumbnailObjectKey,
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', SECRET);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('createR2UploadToken', () => {
|
||||
it('produces a two-part token separated by a dot', () => {
|
||||
expect(createR2UploadToken(SUBJECT).split('.')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('encodes the subject and the issue and expiry times into the payload', () => {
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
||||
|
||||
expect(payload).toEqual({
|
||||
typ: 'r2-upload',
|
||||
uid: 'user-1',
|
||||
pid: 'project-1',
|
||||
key: 'projects/project-1/videos/video-1/source.mp4',
|
||||
sid: 'session-1',
|
||||
jti: 'token-1',
|
||||
tkey: 'projects/project-1/videos/video-1/thumb.jpg',
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to a one hour lifetime', () => {
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
||||
|
||||
expect(payload!.exp - payload!.iat).toBe(3600);
|
||||
});
|
||||
|
||||
it('honours an explicit ttl', () => {
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT, 90));
|
||||
|
||||
expect(payload!.exp - payload!.iat).toBe(90);
|
||||
});
|
||||
|
||||
it('uses base64url, so the token survives a query string unescaped', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||
expect(encodeURIComponent(token)).toBe(token);
|
||||
});
|
||||
|
||||
it('prefers R2_UPLOAD_TOKEN_SECRET over NEXTAUTH_SECRET', () => {
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
// Verifying with only NEXTAUTH_SECRET available must fail, which it can only
|
||||
// do if the dedicated variable was the one that signed.
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to NEXTAUTH_SECRET when the dedicated secret is unset', () => {
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyR2UploadToken(createR2UploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to mint a token when no secret is configured at all', () => {
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
expect(() => createR2UploadToken(SUBJECT)).toThrow(
|
||||
'Missing R2_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyR2UploadToken', () => {
|
||||
it('accepts a freshly signed token for the subject it was minted for', () => {
|
||||
expect(verifyR2UploadToken(createR2UploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token whose payload was tampered with', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
const [encodedPayload, signature] = token.split('.');
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload!, 'base64url').toString('utf8'));
|
||||
payload.pid = 'project-victim';
|
||||
const forged = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
|
||||
expect(verifyR2UploadToken(`${forged}.${signature}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token whose signature was tampered with', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
const [encodedPayload, signature] = token.split('.');
|
||||
const flipped = (signature![0] === 'A' ? 'B' : 'A') + signature!.slice(1);
|
||||
|
||||
expect(verifyR2UploadToken(`${encodedPayload}.${flipped}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token signed under a different secret', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token that has expired', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('still accepts a token in its final second', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 59_000));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a token at the exact expiry second and rejects it one second later', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 60_000));
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(true);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token minted with a zero ttl once the clock moves on', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 0);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 1_000));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a different user', { userId: 'user-2' }],
|
||||
['a different project', { projectId: 'project-2' }],
|
||||
['a different object key', { objectKey: 'projects/project-1/videos/video-2/source.mp4' }],
|
||||
['a different upload session', { sessionId: 'session-2' }],
|
||||
['a different token id', { tokenId: 'token-2' }],
|
||||
['a different thumbnail key', { thumbnailObjectKey: 'projects/other/thumb.jpg' }],
|
||||
])('rejects a valid token presented for %s', (_label, override) => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(verifyR2UploadToken(token, { ...SUBJECT, ...override })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an object key that differs only by a traversal segment', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(
|
||||
verifyR2UploadToken(token, {
|
||||
...SUBJECT,
|
||||
objectKey: 'projects/project-1/videos/video-1/../video-2/source.mp4',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the optional session, token id and thumbnail checks when the caller omits them', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(
|
||||
verifyR2UploadToken(token, {
|
||||
userId: SUBJECT.userId,
|
||||
projectId: SUBJECT.projectId,
|
||||
objectKey: SUBJECT.objectKey,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['whitespace', ' '],
|
||||
['a single segment', 'notatoken'],
|
||||
['three segments', 'a.b.c'],
|
||||
['a missing signature', 'YWJj.'],
|
||||
['a missing payload', '.c2ln'],
|
||||
['two empty segments', '.'],
|
||||
['a jwt-shaped token', 'eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1c2VyLTEifQ.sig'],
|
||||
['punctuation only', '!!!.???'],
|
||||
])('refuses %s rather than throwing', (_label, token) => {
|
||||
expect(() => verifyR2UploadToken(token, SUBJECT)).not.toThrow();
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a signature of the wrong length without letting timingSafeEqual throw', () => {
|
||||
const [encodedPayload] = createR2UploadToken(SUBJECT).split('.');
|
||||
|
||||
// crypto.timingSafeEqual throws on unequal buffer lengths, so the length
|
||||
// guard in front of it is load bearing.
|
||||
expect(() => verifyR2UploadToken(`${encodedPayload}.short`, SUBJECT)).not.toThrow();
|
||||
expect(verifyR2UploadToken(`${encodedPayload}.short`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is not JSON', () => {
|
||||
const encoded = Buffer.from('not json at all', 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', SECRET).update(encoded).digest('base64url');
|
||||
|
||||
expect(verifyR2UploadToken(`${encoded}.${signature}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is a JSON scalar rather than an object', () => {
|
||||
expect(verifyR2UploadToken(signArbitrary('user-1'), SUBJECT)).toBe(false);
|
||||
expect(verifyR2UploadToken(signArbitrary(null), SUBJECT)).toBe(false);
|
||||
expect(verifyR2UploadToken(signArbitrary(42), SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['typ'], ['uid'], ['pid'], ['key'], ['sid'], ['jti'], ['tkey'], ['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload missing %s',
|
||||
(field) => {
|
||||
const payload = wellFormedPayload();
|
||||
delete (payload as Record<string, unknown>)[field];
|
||||
|
||||
expect(verifyR2UploadToken(signArbitrary(payload), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses a correctly signed token minted for a different token type', () => {
|
||||
// Stops a bunny-upload grant, signed with the same NEXTAUTH_SECRET fallback,
|
||||
// from being replayed against the R2 path.
|
||||
expect(
|
||||
verifyR2UploadToken(signArbitrary(wellFormedPayload({ typ: 'bunny-upload' })), SUBJECT)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['exp', 'Infinity', Number.POSITIVE_INFINITY],
|
||||
['exp', 'NaN', Number.NaN],
|
||||
['iat', 'Infinity', Number.POSITIVE_INFINITY],
|
||||
])(
|
||||
'refuses a correctly signed payload whose %s arrives as null, having been minted as %s',
|
||||
(field, _label, value) => {
|
||||
// Named for what it actually exercises. JSON.stringify writes both Infinity
|
||||
// and NaN as `null`, so the payload reaches verify() with a null and is
|
||||
// rejected one line earlier, by the `typeof === 'number'` check. The
|
||||
// Number.isFinite guard is never consulted on this path; the case below is
|
||||
// the one that reaches it.
|
||||
const token = signArbitrary(wellFormedPayload({ [field]: value }));
|
||||
|
||||
expect(
|
||||
JSON.parse(Buffer.from(token.split('.')[0]!, 'base64url').toString())[field]
|
||||
).toBeNull();
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload whose %s is a JSON literal that overflows to Infinity',
|
||||
(field) => {
|
||||
// The one way a non-finite number survives the wire: `1e999` is legal JSON
|
||||
// and JSON.parse turns it into Infinity, which passes the typeof check and
|
||||
// leaves Number.isFinite as the only thing standing. For exp that matters,
|
||||
// because Infinity < now is false, so without the guard the token would
|
||||
// verify and never expire. Minting one still needs the server secret, so
|
||||
// this is defence in depth rather than a reachable forgery.
|
||||
const json = JSON.stringify(wellFormedPayload()).replace(
|
||||
new RegExp(`"${field}":\\d+`),
|
||||
`"${field}":1e999`
|
||||
);
|
||||
|
||||
expect(JSON.parse(json)[field]).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(verifyR2UploadToken(signRawJson(json), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses a correctly signed payload whose exp is a numeric string', () => {
|
||||
const token = signArbitrary(wellFormedPayload({ exp: String(NOW_SECONDS + ONE_HOUR) }));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false rather than throwing when the server has no secret configured', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
// A misconfigured server is indistinguishable from a forged token here.
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseR2UploadToken', () => {
|
||||
it('returns the payload of a valid token', () => {
|
||||
expect(parseR2UploadToken(createR2UploadToken(SUBJECT))).toMatchObject({
|
||||
typ: 'r2-upload',
|
||||
uid: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a token signed under a different secret', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(parseR2UploadToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an expired token', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
|
||||
expect(parseR2UploadToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for garbage input rather than throwing', () => {
|
||||
expect(parseR2UploadToken('')).toBeNull();
|
||||
expect(parseR2UploadToken('a.b.c')).toBeNull();
|
||||
expect(parseR2UploadToken('%%%.%%%')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not check the payload against any subject, leaving that to the caller', () => {
|
||||
// parseR2UploadToken only proves authenticity and freshness. Routes that use
|
||||
// it directly must compare the fields themselves.
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
||||
|
||||
expect(payload!.uid).toBe('user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,760 @@
|
||||
// lib/r2.ts is stubbed wholesale in tests/setup/api.ts so the API suite never
|
||||
// speaks S3. This file stubs the boundary instead: the real S3Client is
|
||||
// constructed and the real presigner runs, only `send()` is replaced. That way
|
||||
// the assertions are about the command objects the module builds (bucket, key,
|
||||
// part number, range, abort path), which is where the bugs would be.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateBucketCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
GetBucketCorsCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutBucketCorsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
|
||||
// lib/r2.ts snapshots every R2_* variable into a module-level const when it is
|
||||
// evaluated, so these have to be in place before the import below runs.
|
||||
// vi.hoisted() is the only hook that fires early enough. All dummy values.
|
||||
vi.hoisted(() => {
|
||||
process.env.R2_ENDPOINT = 'http://minio.test:9000';
|
||||
process.env.R2_ACCESS_KEY_ID = 'unit-test-access-key';
|
||||
process.env.R2_SECRET_ACCESS_KEY = 'unit-test-secret-key';
|
||||
process.env.R2_BUCKET_NAME = 'openframe-unit';
|
||||
delete process.env.R2_ACCOUNT_ID;
|
||||
delete process.env.R2_PRESIGN_ENDPOINT;
|
||||
delete process.env.R2_PUBLIC_BASE_URL;
|
||||
});
|
||||
|
||||
import {
|
||||
R2_BUCKET_NAME,
|
||||
abortMultipartVideoUpload,
|
||||
completeMultipartVideoUpload,
|
||||
createMultipartVideoUpload,
|
||||
createPresignedImagePutUrl,
|
||||
createPresignedUploadPartUrl,
|
||||
createPresignedVideoPutUrl,
|
||||
deleteR2Object,
|
||||
deleteVideoObject,
|
||||
ensureR2BucketExists,
|
||||
ensureR2UploadCors,
|
||||
getR2PublicObjectUrl,
|
||||
getR2UploadCorsOrigins,
|
||||
headVideoObject,
|
||||
readVideoObjectBytes,
|
||||
uploadAudio,
|
||||
} from '@/lib/r2';
|
||||
|
||||
const BUCKET = 'openframe-unit';
|
||||
const VIDEO_KEY = 'videos/11111111-2222-4333-8444-555555555555.mp4';
|
||||
|
||||
let send: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** The command object handed to the Nth send() call. */
|
||||
function commandAt(index: number): { input: Record<string, unknown> } {
|
||||
return send.mock.calls[index][0] as { input: Record<string, unknown> };
|
||||
}
|
||||
|
||||
function inputAt(index: number): Record<string, unknown> {
|
||||
return commandAt(index).input;
|
||||
}
|
||||
|
||||
/** An AWS SDK error carries its HTTP status under $metadata, not on the Error. */
|
||||
function s3Error(httpStatusCode: number | undefined): Error {
|
||||
const error = new Error('s3 rejected the request');
|
||||
if (httpStatusCode !== undefined) {
|
||||
Object.assign(error, { $metadata: { httpStatusCode } });
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
send = vi.spyOn(S3Client.prototype, 'send').mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('R2_BUCKET_NAME', () => {
|
||||
it('re-exports the configured bucket so callers do not read the env twice', () => {
|
||||
expect(R2_BUCKET_NAME).toBe(BUCKET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getR2PublicObjectUrl', () => {
|
||||
it('serves objects from the endpoint and bucket when no public base url is set', () => {
|
||||
expect(getR2PublicObjectUrl('voice/note.webm')).toBe(
|
||||
'http://minio.test:9000/openframe-unit/voice/note.webm'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips leading slashes so the key is never doubled up', () => {
|
||||
expect(getR2PublicObjectUrl('///voice/note.webm')).toBe(
|
||||
'http://minio.test:9000/openframe-unit/voice/note.webm'
|
||||
);
|
||||
});
|
||||
|
||||
// The remaining branches read env captured at module load, so they need a
|
||||
// fresh module registry rather than a stubEnv on the already-loaded copy.
|
||||
async function loadWith(env: Record<string, string | undefined>) {
|
||||
vi.resetModules();
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
vi.stubEnv(name, value);
|
||||
}
|
||||
return import('@/lib/r2');
|
||||
}
|
||||
|
||||
it('prefers R2_PUBLIC_BASE_URL over the endpoint and trims its trailing slashes', async () => {
|
||||
const r2 = await loadWith({ R2_PUBLIC_BASE_URL: 'https://cdn.example.com//' });
|
||||
|
||||
expect(r2.getR2PublicObjectUrl('images/a.png')).toBe('https://cdn.example.com/images/a.png');
|
||||
});
|
||||
|
||||
it('builds the Cloudflare virtual-host url when only an account id is configured', async () => {
|
||||
const r2 = await loadWith({
|
||||
R2_ENDPOINT: undefined,
|
||||
R2_PUBLIC_BASE_URL: undefined,
|
||||
R2_ACCOUNT_ID: 'acct-123',
|
||||
});
|
||||
|
||||
expect(r2.getR2PublicObjectUrl('images/a.png')).toBe(
|
||||
'https://openframe-unit.acct-123.r2.cloudflarestorage.com/images/a.png'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws rather than emitting a half-formed url when nothing is configured', async () => {
|
||||
const r2 = await loadWith({
|
||||
R2_ENDPOINT: undefined,
|
||||
R2_PUBLIC_BASE_URL: undefined,
|
||||
R2_ACCOUNT_ID: undefined,
|
||||
});
|
||||
|
||||
expect(() => r2.getR2PublicObjectUrl('images/a.png')).toThrow(
|
||||
'Missing R2_PUBLIC_BASE_URL or R2_ACCOUNT_ID'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureR2BucketExists', () => {
|
||||
it('stops after the head when the bucket already exists', async () => {
|
||||
await ensureR2BucketExists();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(commandAt(0)).toBeInstanceOf(HeadBucketCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET });
|
||||
});
|
||||
|
||||
it.each([404, 301, 403])('creates the bucket when the head answers %i', async (status) => {
|
||||
send.mockRejectedValueOnce(s3Error(status)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2BucketExists();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(commandAt(1)).toBeInstanceOf(CreateBucketCommand);
|
||||
expect(inputAt(1)).toEqual({ Bucket: BUCKET });
|
||||
});
|
||||
|
||||
it('rethrows an unexpected head failure instead of trying to create the bucket', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(500));
|
||||
|
||||
await expect(ensureR2BucketExists()).rejects.toThrow('s3 rejected the request');
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a failure with no http status as a missing bucket', async () => {
|
||||
// A DNS or socket failure has no $metadata, so the guard falls through.
|
||||
send.mockRejectedValueOnce(s3Error(undefined)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2BucketExists();
|
||||
|
||||
expect(commandAt(1)).toBeInstanceOf(CreateBucketCommand);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAudio', () => {
|
||||
it('writes under the voice prefix and returns the public url', async () => {
|
||||
const url = await uploadAudio(Buffer.from('audio'), 'note.webm');
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(PutObjectCommand);
|
||||
expect(inputAt(0)).toMatchObject({
|
||||
Bucket: BUCKET,
|
||||
Key: 'voice/note.webm',
|
||||
ContentType: 'audio/webm',
|
||||
});
|
||||
expect(url).toBe('http://minio.test:9000/openframe-unit/voice/note.webm');
|
||||
});
|
||||
|
||||
it('keeps only the basename so a traversal cannot escape the voice prefix', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), '../../etc/passwd');
|
||||
|
||||
expect(inputAt(0).Key).toBe('voice/passwd');
|
||||
});
|
||||
|
||||
it('strips a windows-style path separator too', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), 'C:\\Users\\x\\note.webm');
|
||||
|
||||
expect(inputAt(0).Key).toBe('voice/note.webm');
|
||||
});
|
||||
|
||||
it('strips a dot run left behind after the basename is taken', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), 'a..b.webm');
|
||||
|
||||
expect(inputAt(0).Key).toBe('voice/ab.webm');
|
||||
});
|
||||
|
||||
it('rejects a filename that sanitises down to nothing', async () => {
|
||||
await expect(uploadAudio(Buffer.from('audio'), 'dir/')).rejects.toThrow('Invalid filename');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours an explicit content type', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), 'note.mp3', 'audio/mpeg');
|
||||
|
||||
expect(inputAt(0).ContentType).toBe('audio/mpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPresignedVideoPutUrl', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl('images/a.png', 'video/mp4', BigInt(1))
|
||||
).rejects.toThrow('Invalid video object key');
|
||||
});
|
||||
|
||||
it('refuses a key that only mentions the prefix further along', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl('evil/videos/a.mp4', 'video/mp4', BigInt(1))
|
||||
).rejects.toThrow('Invalid video object key');
|
||||
});
|
||||
|
||||
it.each([BigInt(0), BigInt(-1)])('refuses a content length of %s', async (length) => {
|
||||
await expect(createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', length)).rejects.toThrow(
|
||||
'Invalid video content length'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a content length past the safe integer range', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl(
|
||||
VIDEO_KEY,
|
||||
'video/mp4',
|
||||
BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)
|
||||
)
|
||||
).rejects.toThrow('Invalid video content length');
|
||||
});
|
||||
|
||||
it('accepts the largest representable content length', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(Number.MAX_SAFE_INTEGER))
|
||||
).resolves.toContain('X-Amz-Signature=');
|
||||
});
|
||||
|
||||
it('signs a one hour PUT against the bucket and key by default', async () => {
|
||||
const url = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
||||
|
||||
// Everything here is deterministic; the signature itself deliberately is not.
|
||||
expect(url.origin).toBe('http://minio.test:9000');
|
||||
expect(url.pathname).toBe(`/${BUCKET}/${VIDEO_KEY}`);
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
|
||||
expect(url.searchParams.get('x-id')).toBe('PutObject');
|
||||
expect(url.searchParams.get('X-Amz-Signature')).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours a caller-supplied expiry window', async () => {
|
||||
const url = new URL(
|
||||
await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024), 900)
|
||||
);
|
||||
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('900');
|
||||
});
|
||||
|
||||
it('binds the content length into the signature so the size cannot be swapped', async () => {
|
||||
const url = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
||||
|
||||
expect(url.searchParams.get('X-Amz-SignedHeaders')?.split(';')).toContain('content-length');
|
||||
});
|
||||
|
||||
it('produces a different signature for a different key', async () => {
|
||||
const a = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
||||
const b = new URL(
|
||||
await createPresignedVideoPutUrl('videos/other.mp4', 'video/mp4', BigInt(1024))
|
||||
);
|
||||
|
||||
expect(a.searchParams.get('X-Amz-Signature')).not.toBe(b.searchParams.get('X-Amz-Signature'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPresignedImagePutUrl', () => {
|
||||
it('refuses a key outside the images prefix', async () => {
|
||||
await expect(createPresignedImagePutUrl(VIDEO_KEY, 'image/png')).rejects.toThrow(
|
||||
'Invalid image object key'
|
||||
);
|
||||
});
|
||||
|
||||
it('signs a PUT against the bucket and key', async () => {
|
||||
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png', 120));
|
||||
|
||||
expect(url.origin).toBe('http://minio.test:9000');
|
||||
expect(url.pathname).toBe(`/${BUCKET}/images/avatar.png`);
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('120');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to a one hour window', async () => {
|
||||
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
|
||||
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
|
||||
});
|
||||
|
||||
// Documents current behaviour rather than endorsing it: ContentType is passed
|
||||
// to the command but the presigner does not sign it, so the grant does not
|
||||
// pin the uploaded media type. See the note in the review notes.
|
||||
it('does not bind the content type into the signature', async () => {
|
||||
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
|
||||
|
||||
expect(url.searchParams.get('X-Amz-SignedHeaders')).toBe('host');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMultipartVideoUpload', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(createMultipartVideoUpload('images/a.png', 'video/mp4')).rejects.toThrow(
|
||||
'Invalid video object key'
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the upload id the service assigned', async () => {
|
||||
send.mockResolvedValueOnce({ UploadId: 'upload-abc' } as never);
|
||||
|
||||
await expect(createMultipartVideoUpload(VIDEO_KEY, 'video/mp4')).resolves.toBe('upload-abc');
|
||||
expect(commandAt(0)).toBeInstanceOf(CreateMultipartUploadCommand);
|
||||
expect(inputAt(0)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
Key: VIDEO_KEY,
|
||||
ContentType: 'video/mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the service answers without an upload id', async () => {
|
||||
send.mockResolvedValueOnce({} as never);
|
||||
|
||||
await expect(createMultipartVideoUpload(VIDEO_KEY, 'video/mp4')).rejects.toThrow(
|
||||
'Failed to create multipart upload'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPresignedUploadPartUrl', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(createPresignedUploadPartUrl('images/a.png', 'upload-1', 1)).rejects.toThrow(
|
||||
'Invalid video object key'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, -1, 10001, 1.5, Number.NaN])('refuses part number %s', async (partNumber) => {
|
||||
await expect(createPresignedUploadPartUrl(VIDEO_KEY, 'upload-1', partNumber)).rejects.toThrow(
|
||||
'Invalid part number'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 10000])('accepts the boundary part number %i', async (partNumber) => {
|
||||
const url = new URL(await createPresignedUploadPartUrl(VIDEO_KEY, 'upload-1', partNumber));
|
||||
|
||||
expect(url.searchParams.get('partNumber')).toBe(String(partNumber));
|
||||
});
|
||||
|
||||
it('signs the part number and upload id into the query string', async () => {
|
||||
const url = new URL(await createPresignedUploadPartUrl(VIDEO_KEY, 'upload-abc', 7, 600));
|
||||
|
||||
expect(url.origin).toBe('http://minio.test:9000');
|
||||
expect(url.pathname).toBe(`/${BUCKET}/${VIDEO_KEY}`);
|
||||
expect(url.searchParams.get('partNumber')).toBe('7');
|
||||
expect(url.searchParams.get('uploadId')).toBe('upload-abc');
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('600');
|
||||
expect(url.searchParams.get('x-id')).toBe('UploadPart');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeMultipartVideoUpload', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(
|
||||
completeMultipartVideoUpload('images/a.png', 'upload-1', [{ partNumber: 1, etag: 'e1' }])
|
||||
).rejects.toThrow('Invalid video object key');
|
||||
});
|
||||
|
||||
it('refuses an empty part list rather than completing an empty object', async () => {
|
||||
await expect(completeMultipartVideoUpload(VIDEO_KEY, 'upload-1', [])).rejects.toThrow(
|
||||
'No parts provided for multipart completion'
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sorts the parts by number because S3 rejects an out-of-order manifest', async () => {
|
||||
await completeMultipartVideoUpload(VIDEO_KEY, 'upload-abc', [
|
||||
{ partNumber: 3, etag: 'etag-3' },
|
||||
{ partNumber: 1, etag: 'etag-1' },
|
||||
{ partNumber: 2, etag: 'etag-2' },
|
||||
]);
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(CompleteMultipartUploadCommand);
|
||||
expect(inputAt(0)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
Key: VIDEO_KEY,
|
||||
UploadId: 'upload-abc',
|
||||
MultipartUpload: {
|
||||
Parts: [
|
||||
{ PartNumber: 1, ETag: 'etag-1' },
|
||||
{ PartNumber: 2, ETag: 'etag-2' },
|
||||
{ PartNumber: 3, ETag: 'etag-3' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reorder the array the caller passed in', async () => {
|
||||
const parts = [
|
||||
{ partNumber: 2, etag: 'etag-2' },
|
||||
{ partNumber: 1, etag: 'etag-1' },
|
||||
];
|
||||
|
||||
await completeMultipartVideoUpload(VIDEO_KEY, 'upload-abc', parts);
|
||||
|
||||
expect(parts.map((part) => part.partNumber)).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abortMultipartVideoUpload', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(abortMultipartVideoUpload('images/a.png', 'upload-1')).rejects.toThrow(
|
||||
'Invalid video object key'
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the named upload on the named key', async () => {
|
||||
await abortMultipartVideoUpload(VIDEO_KEY, 'upload-abc');
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(AbortMultipartUploadCommand);
|
||||
expect(inputAt(0)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
Key: VIDEO_KEY,
|
||||
UploadId: 'upload-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates a failed abort so the caller can retry or alarm', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(500));
|
||||
|
||||
await expect(abortMultipartVideoUpload(VIDEO_KEY, 'upload-abc')).rejects.toThrow(
|
||||
's3 rejected the request'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headVideoObject', () => {
|
||||
it('returns null for a key outside the videos prefix without touching the network', async () => {
|
||||
await expect(headVideoObject('images/a.png')).resolves.toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the length as a bigint and passes the content type through', async () => {
|
||||
send.mockResolvedValueOnce({ ContentLength: 4096, ContentType: 'video/mp4' } as never);
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
||||
contentLength: BigInt(4096),
|
||||
contentType: 'video/mp4',
|
||||
});
|
||||
expect(commandAt(0)).toBeInstanceOf(HeadObjectCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY });
|
||||
});
|
||||
|
||||
it('falls back to zero when the service omits the length', async () => {
|
||||
send.mockResolvedValueOnce({ ContentType: 'video/mp4' } as never);
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
||||
contentLength: BigInt(0),
|
||||
contentType: 'video/mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to zero rather than throwing on a negative length', async () => {
|
||||
send.mockResolvedValueOnce({ ContentLength: -1 } as never);
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
||||
contentLength: BigInt(0),
|
||||
contentType: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a missing object', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(404));
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rethrows any other failure so a broken bucket is not read as an empty one', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(403));
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).rejects.toThrow('s3 rejected the request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readVideoObjectBytes', () => {
|
||||
function bodyOf(bytes: Uint8Array) {
|
||||
return { Body: { transformToByteArray: async () => bytes } };
|
||||
}
|
||||
|
||||
it('returns null for a key outside the videos prefix', async () => {
|
||||
await expect(readVideoObjectBytes('images/a.png', 16)).resolves.toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, -5])('returns null for a byte length of %i', async (byteLength) => {
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, byteLength)).resolves.toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests an inclusive range that is one byte shorter than the length asked for', async () => {
|
||||
send.mockResolvedValueOnce(bodyOf(new Uint8Array([1, 2, 3, 4])) as never);
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
expect(commandAt(0)).toBeInstanceOf(GetObjectCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY, Range: 'bytes=0-3' });
|
||||
});
|
||||
|
||||
it('asks for a single byte when one byte is requested', async () => {
|
||||
send.mockResolvedValueOnce(bodyOf(new Uint8Array([1])) as never);
|
||||
|
||||
await readVideoObjectBytes(VIDEO_KEY, 1);
|
||||
|
||||
expect(inputAt(0).Range).toBe('bytes=0-0');
|
||||
});
|
||||
|
||||
it('returns null when the response carries no body', async () => {
|
||||
send.mockResolvedValueOnce({} as never);
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the body cannot be collected into bytes', async () => {
|
||||
send.mockResolvedValueOnce({ Body: {} } as never);
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([404, 416])('returns null when the range read answers %i', async (status) => {
|
||||
send.mockRejectedValueOnce(s3Error(status));
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rethrows any other read failure', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(500));
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).rejects.toThrow('s3 rejected the request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteVideoObject and deleteR2Object', () => {
|
||||
it('deletes a video key', async () => {
|
||||
await deleteVideoObject(VIDEO_KEY);
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(DeleteObjectCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY });
|
||||
});
|
||||
|
||||
it('refuses an image key through the video-specific entry point', async () => {
|
||||
await expect(deleteVideoObject('images/a.png')).rejects.toThrow('Invalid video object key');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an image key through the general entry point', async () => {
|
||||
await deleteR2Object('images/a.png');
|
||||
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: 'images/a.png' });
|
||||
});
|
||||
|
||||
// The allowlist is the whole safety story for delete: anything that is not a
|
||||
// video or an image key must never reach DeleteObject.
|
||||
it.each([
|
||||
'voice/note.webm',
|
||||
'',
|
||||
'/videos/a.mp4',
|
||||
'other/videos/a.mp4',
|
||||
'../videos/a.mp4',
|
||||
'videos',
|
||||
])('refuses to delete %s', async (key) => {
|
||||
await expect(deleteR2Object(key)).rejects.toThrow('Invalid object key');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getR2UploadCorsOrigins', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
});
|
||||
|
||||
it('reduces each configured url to its origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com/some/path');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['https://app.example.com']);
|
||||
});
|
||||
|
||||
it('deduplicates urls that share an origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com/');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['https://app.example.com']);
|
||||
});
|
||||
|
||||
it('keeps the port, which is what makes a local origin distinct', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'http://localhost:3000');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['http://localhost:3000']);
|
||||
});
|
||||
|
||||
it('appends caller-supplied origins after the configured ones', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
|
||||
expect(getR2UploadCorsOrigins(['https://extra.example.com'])).toEqual([
|
||||
'https://app.example.com',
|
||||
'https://extra.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips blank and unparseable entries instead of throwing', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', ' ');
|
||||
|
||||
expect(getR2UploadCorsOrigins(['not a url', ''])).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds the loopback development origins only in development', () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['http://localhost:3000', 'http://127.0.0.1:3000']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureR2UploadCors', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
});
|
||||
|
||||
const managedRule = {
|
||||
AllowedOrigins: ['https://app.example.com'],
|
||||
AllowedMethods: ['GET', 'PUT', 'HEAD'],
|
||||
AllowedHeaders: ['*'],
|
||||
ExposeHeaders: ['ETag'],
|
||||
MaxAgeSeconds: 3600,
|
||||
};
|
||||
|
||||
it('refuses to run with no origins rather than opening the bucket to everyone', async () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
|
||||
await expect(ensureR2UploadCors()).rejects.toThrow('No origins configured for R2 upload CORS');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an existing rule alone when it already covers the origin', async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
CORSRules: [{ AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['GET', 'PUT'] }],
|
||||
} as never);
|
||||
|
||||
await expect(ensureR2UploadCors()).resolves.toEqual(['https://app.example.com']);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(commandAt(0)).toBeInstanceOf(GetBucketCorsCommand);
|
||||
});
|
||||
|
||||
it('accepts HEAD in place of GET on the existing rule', async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
CORSRules: [{ AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['head', 'put'] }],
|
||||
} as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('appends its own rule when the existing rules omit PUT', async () => {
|
||||
const existing = { AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['GET'] };
|
||||
send.mockResolvedValueOnce({ CORSRules: [existing] } as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(commandAt(1)).toBeInstanceOf(PutBucketCorsCommand);
|
||||
expect(inputAt(1)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
CORSConfiguration: { CORSRules: [existing, managedRule] },
|
||||
});
|
||||
});
|
||||
|
||||
it('appends its own rule when the existing rules cover a different origin', async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
CORSRules: [
|
||||
{ AllowedOrigins: ['https://other.example.com'], AllowedMethods: ['GET', 'PUT'] },
|
||||
],
|
||||
} as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(commandAt(1)).toBeInstanceOf(PutBucketCorsCommand);
|
||||
});
|
||||
|
||||
it('writes a fresh configuration when the bucket has no CORS config to read', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(404)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await expect(ensureR2UploadCors()).resolves.toEqual(['https://app.example.com']);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(inputAt(1)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
CORSConfiguration: { CORSRules: [managedRule] },
|
||||
});
|
||||
});
|
||||
|
||||
// The try block wraps the write as well as the read, so a write that fails
|
||||
// lands in the same catch as "no config to read" and the retry re-sends only
|
||||
// the managed rule. Asserted as-is; see the review notes.
|
||||
it('drops the pre-existing rules when the first write fails and the retry succeeds', async () => {
|
||||
const existing = { AllowedOrigins: ['https://other.example.com'], AllowedMethods: ['GET'] };
|
||||
send
|
||||
.mockResolvedValueOnce({ CORSRules: [existing] } as never)
|
||||
.mockRejectedValueOnce(s3Error(500))
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
expect(inputAt(2)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
CORSConfiguration: { CORSRules: [managedRule] },
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the extra origins it was handed in the rule it writes', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(404)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2UploadCors(['https://preview.example.com']);
|
||||
|
||||
expect(
|
||||
(inputAt(1).CORSConfiguration as { CORSRules: Array<{ AllowedOrigins: string[] }> })
|
||||
.CORSRules[0].AllowedOrigins
|
||||
).toEqual(['https://app.example.com', 'https://preview.example.com']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import {
|
||||
getAllowedRequestOrigins,
|
||||
getPublicOrigin,
|
||||
isTrustedSameOriginRequest,
|
||||
} from '@/lib/request-origin';
|
||||
|
||||
const APP_URL = 'https://app.openframe.test';
|
||||
const REQUEST_URL = `${APP_URL}/api/billing/checkout`;
|
||||
|
||||
function request(headers: Record<string, string> = {}, url = REQUEST_URL): NextRequest {
|
||||
return new NextRequest(url, { method: 'POST', headers: new Headers(headers) });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The `unit` project loads no env file, so whatever the shell happens to export
|
||||
// would otherwise decide the allowed-origin set. Pin both variables.
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('isTrustedSameOriginRequest', () => {
|
||||
it('trusts a request whose Origin matches the request origin', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: APP_URL }))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a request from a different origin', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://evil.example.com' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a request with no Origin header at all', () => {
|
||||
expect(isTrustedSameOriginRequest(request())).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses the literal "null" Origin a sandboxed iframe sends', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'null' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an empty Origin header', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: '' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an unparseable Origin instead of throwing', () => {
|
||||
expect(() => isTrustedSameOriginRequest(request({ origin: 'not a url' }))).not.toThrow();
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'not a url' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a different scheme on the same host', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'http://app.openframe.test' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a different port on the same host', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://app.openframe.test:8443' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an attacker subdomain of the trusted host', () => {
|
||||
expect(
|
||||
isTrustedSameOriginRequest(request({ origin: 'https://app.openframe.test.evil.com' }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a host that merely starts with the trusted host', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://app.openframe.testing' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('trusts an operator-configured origin that differs from the request origin', () => {
|
||||
// The Docker case: the container sees localhost:3000, the browser sees the
|
||||
// public hostname, and the Origin header carries the latter.
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://frames.example.com');
|
||||
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request(
|
||||
{ origin: 'https://frames.example.com' },
|
||||
'http://localhost:3000/api/billing/portal'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts an origin configured through NEXTAUTH_URL', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://frames.example.com/api/auth');
|
||||
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request(
|
||||
{ origin: 'https://frames.example.com' },
|
||||
'http://localhost:3000/api/billing/portal'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('still refuses a third origin when both variables are configured', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://c.example.com' }))).toBe(false);
|
||||
});
|
||||
|
||||
// The header comment in lib/request-origin.ts calls this out explicitly: the
|
||||
// x-forwarded-* headers are client controlled, so trusting them would let any
|
||||
// caller name its own origin as the allowed one.
|
||||
it('does not let a forged x-forwarded-host widen the allowed set', () => {
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request({
|
||||
origin: 'https://evil.example.com',
|
||||
'x-forwarded-host': 'evil.example.com',
|
||||
'x-forwarded-proto': 'https',
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let a forged Host header widen the allowed set', () => {
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request({ origin: 'https://evil.example.com', host: 'evil.example.com' })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('compares only the origin, ignoring a path or query the caller appended', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: `${APP_URL}/some/path?a=1` }))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores case in the scheme and host, as URL parsing normalizes both', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'HTTPS://APP.OPENFRAME.TEST' }))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllowedRequestOrigins', () => {
|
||||
it('always contains the server-computed request origin', () => {
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
|
||||
it('adds both configured origins alongside the request origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(
|
||||
new Set([APP_URL, 'https://a.example.com', 'https://b.example.com'])
|
||||
);
|
||||
});
|
||||
|
||||
it('reduces a configured url with a path down to its origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com/api/auth/callback');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toContain('https://a.example.com');
|
||||
});
|
||||
|
||||
it('assumes https for a configured value with no scheme', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'frames.example.com');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toContain('https://frames.example.com');
|
||||
});
|
||||
|
||||
it('keeps an explicitly configured http origin as http', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toContain('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('skips a blank or whitespace-only configured value', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', ' ');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', '');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
|
||||
it('skips a configured value that cannot be parsed as a url', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
|
||||
it('collapses duplicate configured origins into one entry', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://frames.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://frames.example.com/dashboard');
|
||||
|
||||
expect(getAllowedRequestOrigins(request()).size).toBe(2);
|
||||
});
|
||||
|
||||
it('never contains an x-forwarded-derived origin', () => {
|
||||
const origins = getAllowedRequestOrigins(
|
||||
request({ 'x-forwarded-host': 'evil.example.com', 'x-forwarded-proto': 'https' })
|
||||
);
|
||||
|
||||
expect(origins).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicOrigin', () => {
|
||||
it('prefers NEXTAUTH_URL over both the request origin and NEXT_PUBLIC_APP_URL', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://a.example.com');
|
||||
});
|
||||
|
||||
it('falls back to NEXT_PUBLIC_APP_URL when NEXTAUTH_URL is unset', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://b.example.com');
|
||||
});
|
||||
|
||||
it('falls back to the request origin when neither variable is configured', () => {
|
||||
// The local development case, where no reverse proxy sits in front.
|
||||
expect(getPublicOrigin(request())).toBe(APP_URL);
|
||||
});
|
||||
|
||||
it('strips the path from a configured url', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com/api/auth');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://a.example.com');
|
||||
});
|
||||
|
||||
it('assumes https for a configured host with no scheme', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'frames.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://frames.example.com');
|
||||
});
|
||||
|
||||
it('skips a whitespace-only NEXTAUTH_URL and uses the next candidate', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', ' ');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://b.example.com');
|
||||
});
|
||||
|
||||
it('skips an unparseable NEXTAUTH_URL and uses the next candidate', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://b.example.com');
|
||||
});
|
||||
|
||||
it('does not build the redirect origin from a forged x-forwarded-host', () => {
|
||||
// This is the value the browser is sent to, so a spoofed host here is an
|
||||
// open redirect.
|
||||
expect(
|
||||
getPublicOrigin(
|
||||
request({ 'x-forwarded-host': 'evil.example.com', 'x-forwarded-proto': 'https' })
|
||||
)
|
||||
).toBe(APP_URL);
|
||||
});
|
||||
|
||||
it('preserves the port of the request origin when falling back', () => {
|
||||
expect(getPublicOrigin(request({}, 'http://localhost:3000/api/auth/verify-email'))).toBe(
|
||||
'http://localhost:3000'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,716 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||
import {
|
||||
hasAppNavigationAccess,
|
||||
hasCollaboratorBillingBackedAccess,
|
||||
requireAuthOrRedirect,
|
||||
requireBillingAccessOrRedirect,
|
||||
requireProjectAccessOrRedirect,
|
||||
requireVideoProjectAccessOrRedirect,
|
||||
requireWorkspaceAccessOrRedirect,
|
||||
} from '@/lib/route-access';
|
||||
|
||||
// The real redirect() and notFound() abort rendering by throwing. A mock that
|
||||
// returns normally would let execution fall through into code that can never run
|
||||
// in production, and every assertion after that point would describe a fiction.
|
||||
// In particular lib/route-access.ts has branches that call redirectForMissingAuth()
|
||||
// and then redirectForForbidden() on the following line; only a throwing mock
|
||||
// shows which of the two a real request would land on.
|
||||
const nav = vi.hoisted(() => {
|
||||
class RedirectError extends Error {
|
||||
constructor(readonly path: string) {
|
||||
super(`NEXT_REDIRECT ${path}`);
|
||||
}
|
||||
}
|
||||
class NotFoundError extends Error {
|
||||
constructor() {
|
||||
super('NEXT_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
return {
|
||||
RedirectError,
|
||||
NotFoundError,
|
||||
redirect: vi.fn((path: string): never => {
|
||||
throw new RedirectError(path);
|
||||
}),
|
||||
notFound: vi.fn((): never => {
|
||||
throw new NotFoundError();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('next/navigation', () => ({ redirect: nav.redirect, notFound: nav.notFound }));
|
||||
|
||||
// The permission formulas themselves live in lib/auth.ts and are covered by
|
||||
// tests/unit/lib/project-access.test.ts against the real matrix. Here they are
|
||||
// stubbed so each test can pin one access verdict and assert only on what
|
||||
// route-access.ts does with it.
|
||||
const authModule = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
checkProjectAccess: vi.fn(),
|
||||
checkWorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth', () => authModule);
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
workspace: { findUnique: vi.fn(), count: vi.fn() },
|
||||
project: { findUnique: vi.fn(), count: vi.fn() },
|
||||
video: { findFirst: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
|
||||
|
||||
// The three redirect targets are written out by hand rather than imported, so
|
||||
// changing a target in lib/route-access.ts fails here instead of silently
|
||||
// agreeing with itself. /login and /dashboard match what the pages that do their
|
||||
// own session check use (app/(dashboard)/dashboard/page.tsx redirects anonymous
|
||||
// callers to /login); /settings is the page that renders the billing-only view
|
||||
// when hasBillingAccess is false.
|
||||
const LOGIN = '/login';
|
||||
const FORBIDDEN = '/dashboard';
|
||||
const BILLING = '/settings';
|
||||
|
||||
const NOW = new Date('2026-01-15T00:00:00.000Z');
|
||||
|
||||
const USER_ID = 'user-signed-in';
|
||||
const OTHER_USER_ID = 'user-from-session';
|
||||
const PROJECT_ID = 'project-1';
|
||||
const WORKSPACE_ID = 'workspace-1';
|
||||
const VIDEO_ID = 'video-1';
|
||||
|
||||
const ACTIVE_BILLING = {
|
||||
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
|
||||
trialEndsAt: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
};
|
||||
|
||||
const LAPSED_BILLING = {
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
trialEndsAt: new Date('2025-12-01T00:00:00.000Z'),
|
||||
stripeCurrentPeriodEnd: new Date('2025-12-08T00:00:00.000Z'),
|
||||
billingAccessEndedAt: new Date('2025-12-08T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const PROJECT_ROW = {
|
||||
id: PROJECT_ID,
|
||||
ownerId: USER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
visibility: 'PRIVATE',
|
||||
};
|
||||
|
||||
const PUBLIC_PROJECT_ROW = { ...PROJECT_ROW, visibility: 'PUBLIC' };
|
||||
|
||||
const WORKSPACE_ROW = { id: WORKSPACE_ID, ownerId: USER_ID };
|
||||
|
||||
const VIDEO_ROW = { id: VIDEO_ID, project: PROJECT_ROW };
|
||||
|
||||
type ProjectAccessResult = {
|
||||
isOwner: boolean;
|
||||
isProjectMember: boolean;
|
||||
isProjectAdmin: boolean;
|
||||
isWorkspaceMember: boolean;
|
||||
isWorkspaceAdmin: boolean;
|
||||
hasAccess: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
ownerBillingActive: boolean;
|
||||
};
|
||||
|
||||
function projectAccess(overrides: Partial<ProjectAccessResult> = {}): ProjectAccessResult {
|
||||
return {
|
||||
isOwner: false,
|
||||
isProjectMember: false,
|
||||
isProjectAdmin: false,
|
||||
isWorkspaceMember: false,
|
||||
isWorkspaceAdmin: false,
|
||||
hasAccess: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
ownerBillingActive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceAccessResult = {
|
||||
isOwner: boolean;
|
||||
isMember: boolean;
|
||||
isAdmin: boolean;
|
||||
hasAccess: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
ownerBillingActive: boolean;
|
||||
};
|
||||
|
||||
function workspaceAccess(overrides: Partial<WorkspaceAccessResult> = {}): WorkspaceAccessResult {
|
||||
return {
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
isAdmin: false,
|
||||
hasAccess: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
ownerBillingActive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the call aborted through redirect() with exactly one target. The
|
||||
* "exactly one" half matters: several branches queue a second redirect on the
|
||||
* line below, and only the first one can ever take effect at runtime.
|
||||
*/
|
||||
async function expectRedirect(call: Promise<unknown>, path: string) {
|
||||
await expect(call).rejects.toBeInstanceOf(nav.RedirectError);
|
||||
expect(nav.redirect).toHaveBeenCalledTimes(1);
|
||||
expect(nav.redirect).toHaveBeenCalledWith(path);
|
||||
expect(nav.notFound).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectNotFound(call: Promise<unknown>) {
|
||||
await expect(call).rejects.toBeInstanceOf(nav.NotFoundError);
|
||||
expect(nav.notFound).toHaveBeenCalledTimes(1);
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// hasBillingAccess() short-circuits to true when Stripe is off, which would
|
||||
// make every lapsed-billing fixture read as paid.
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('requireAuthOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireAuthOrRedirect(), LOGIN);
|
||||
});
|
||||
|
||||
it('sends a session with no user id to the login page', async () => {
|
||||
// next-auth can hand back a session object whose user was never resolved.
|
||||
authModule.auth.mockResolvedValue({ user: { email: '[email protected]' } });
|
||||
|
||||
await expectRedirect(requireAuthOrRedirect(), LOGIN);
|
||||
});
|
||||
|
||||
it('returns the session untouched for a signed-in caller', async () => {
|
||||
const session = { user: { id: USER_ID, email: '[email protected]' } };
|
||||
authModule.auth.mockResolvedValue(session);
|
||||
|
||||
await expect(requireAuthOrRedirect()).resolves.toEqual(session);
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireBillingAccessOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page without reading the user row', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireBillingAccessOrRedirect(), LOGIN);
|
||||
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a caller whose user row is gone to the billing settings page', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireBillingAccessOrRedirect({ userId: USER_ID }), BILLING);
|
||||
});
|
||||
|
||||
it('sends a caller whose billing has lapsed to the billing settings page', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
|
||||
|
||||
await expectRedirect(requireBillingAccessOrRedirect({ userId: USER_ID }), BILLING);
|
||||
});
|
||||
|
||||
it('returns the billing columns for a caller who is still paying', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
|
||||
|
||||
await expect(requireBillingAccessOrRedirect({ userId: USER_ID })).resolves.toEqual(
|
||||
ACTIVE_BILLING
|
||||
);
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps access for a caller inside an unexpired trial', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue({
|
||||
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||
trialEndsAt: new Date('2026-01-16T00:00:00.000Z'),
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
});
|
||||
|
||||
await expect(requireBillingAccessOrRedirect({ userId: USER_ID })).resolves.toBeTruthy();
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trusts the caller-supplied user id over the session', async () => {
|
||||
// Pages that already resolved a session pass the id down to save a round
|
||||
// trip; the passed id has to win, or one user is billed against another.
|
||||
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
|
||||
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
|
||||
|
||||
await requireBillingAccessOrRedirect({ userId: USER_ID });
|
||||
|
||||
expect(dbMock.user.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: USER_ID } })
|
||||
);
|
||||
expect(authModule.auth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCollaboratorBillingBackedAccess', () => {
|
||||
beforeEach(() => {
|
||||
dbMock.workspace.count.mockResolvedValue(0);
|
||||
dbMock.project.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('is true when the caller belongs to a workspace whose owner is paying', async () => {
|
||||
dbMock.workspace.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is true when the caller belongs to a project whose workspace owner is paying', async () => {
|
||||
dbMock.project.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the caller collaborates nowhere', async () => {
|
||||
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('counts only workspaces and projects whose owner is inside the billing window', async () => {
|
||||
await hasCollaboratorBillingBackedAccess(USER_ID);
|
||||
|
||||
// buildBillingAccessWhereInput comes from lib/billing, a separately tested
|
||||
// module, so this pins the filter without reading it out of route-access.
|
||||
const billingFilter = buildBillingAccessWhereInput(NOW);
|
||||
expect(dbMock.workspace.count.mock.calls[0][0].where.owner).toEqual(billingFilter);
|
||||
expect(dbMock.project.count.mock.calls[0][0].where.workspace.owner).toEqual(billingFilter);
|
||||
});
|
||||
|
||||
it('counts a workspace the caller owns and one they were only invited to', async () => {
|
||||
// The membership arm is the whole point of the workspace half: a collaborator
|
||||
// who owns no workspace of their own would lose dashboard navigation without
|
||||
// it, and the project count only papers over that while they happen to sit on
|
||||
// at least one project row.
|
||||
await hasCollaboratorBillingBackedAccess(USER_ID);
|
||||
|
||||
expect(dbMock.workspace.count.mock.calls[0][0].where.OR).toEqual([
|
||||
{ ownerId: USER_ID },
|
||||
{ members: { some: { userId: USER_ID } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts a project reached only through workspace membership', async () => {
|
||||
// A workspace COMMENTATOR is on no project row, so dropping this arm would
|
||||
// strip navigation from every workspace-level collaborator.
|
||||
await hasCollaboratorBillingBackedAccess(USER_ID);
|
||||
|
||||
expect(dbMock.project.count.mock.calls[0][0].where.OR).toEqual([
|
||||
{ ownerId: USER_ID },
|
||||
{ members: { some: { userId: USER_ID } } },
|
||||
{ workspace: { members: { some: { userId: USER_ID } } } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAppNavigationAccess', () => {
|
||||
beforeEach(() => {
|
||||
dbMock.workspace.count.mockResolvedValue(0);
|
||||
dbMock.project.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('is true for a paying user without counting collaborations', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
|
||||
expect(dbMock.workspace.count).not.toHaveBeenCalled();
|
||||
expect(dbMock.project.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is true for a lapsed user who still collaborates on a paid workspace', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
|
||||
dbMock.workspace.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is true when the user row is missing but a collaboration exists', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(null);
|
||||
dbMock.project.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a lapsed user with nothing left to collaborate on', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireWorkspaceAccessOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page before the workspace is read', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID }), LOGIN);
|
||||
expect(dbMock.workspace.findUnique).not.toHaveBeenCalled();
|
||||
expect(authModule.checkWorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a 404 for a signed-in caller when the workspace does not exist', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectNotFound(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: USER_ID })
|
||||
);
|
||||
expect(authModule.checkWorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a signed-in stranger to the dashboard', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(workspaceAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the owner to the billing settings page when their billing has lapsed', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(
|
||||
workspaceAccess({ isOwner: true, hasAccess: false, ownerBillingActive: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: USER_ID }),
|
||||
BILLING
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a member who cannot edit to the dashboard when the page needs manage rights', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(
|
||||
workspaceAccess({ isMember: true, hasAccess: true, canEdit: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireWorkspaceAccessOrRedirect({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
intent: 'manage',
|
||||
}),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a member through on the default view intent even though they cannot edit', async () => {
|
||||
const access = workspaceAccess({ isMember: true, hasAccess: true, canEdit: false });
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID })
|
||||
).resolves.toEqual({ workspace: WORKSPACE_ROW, access });
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
expect(nav.notFound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an admin through on the manage intent', async () => {
|
||||
const access = workspaceAccess({
|
||||
isMember: true,
|
||||
isAdmin: true,
|
||||
hasAccess: true,
|
||||
canEdit: true,
|
||||
});
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireWorkspaceAccessOrRedirect({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
intent: 'manage',
|
||||
})
|
||||
).resolves.toEqual({ workspace: WORKSPACE_ROW, access });
|
||||
});
|
||||
|
||||
it('falls back to the session user when no id is passed', async () => {
|
||||
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(
|
||||
workspaceAccess({ isOwner: true, hasAccess: true, canEdit: true })
|
||||
);
|
||||
|
||||
await requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID });
|
||||
|
||||
expect(authModule.checkWorkspaceAccess).toHaveBeenCalledWith(WORKSPACE_ROW, OTHER_USER_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireProjectAccessOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page before the project is read', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireProjectAccessOrRedirect({ projectId: PROJECT_ID }), LOGIN);
|
||||
expect(dbMock.project.findUnique).not.toHaveBeenCalled();
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page on a public route asking for manage rights', async () => {
|
||||
// The guest policy runs before any permission check: a guest can only ever
|
||||
// read, so a manage page is a login redirect regardless of the project.
|
||||
dbMock.project.findUnique.mockResolvedValue(PUBLIC_PROJECT_ROW);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({
|
||||
projectId: PROJECT_ID,
|
||||
intent: 'manage',
|
||||
allowPublicView: true,
|
||||
}),
|
||||
LOGIN
|
||||
);
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page rather than a 404 for a missing project', async () => {
|
||||
// A guest must not be able to tell a project that does not exist apart from
|
||||
// one they cannot see; both answers have to look the same.
|
||||
dbMock.project.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page, not the dashboard, when a public route holds a private project', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a 404 for a signed-in caller when the project does not exist', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectNotFound(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: USER_ID })
|
||||
);
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a signed-in stranger to the dashboard', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: OTHER_USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the owner to the dashboard when the workspace owner billing has lapsed', async () => {
|
||||
// Unlike the workspace helper this path has no /settings branch: a lapsed
|
||||
// owner lands on /dashboard, which runs its own billing gate and forwards
|
||||
// them to /settings from there.
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isOwner: true, hasAccess: false, ownerBillingActive: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a read-only member to the dashboard when the page needs manage rights', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isProjectMember: true, hasAccess: true, canEdit: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({
|
||||
projectId: PROJECT_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
intent: 'manage',
|
||||
}),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the project row and the access verdict for a permitted viewer', async () => {
|
||||
const access = projectAccess({ isProjectMember: true, hasAccess: true });
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: OTHER_USER_ID })
|
||||
).resolves.toEqual({ project: PROJECT_ROW, access });
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
expect(nav.notFound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an anonymous viewer read a public project when the route opts in', async () => {
|
||||
const access = projectAccess({ hasAccess: true });
|
||||
dbMock.project.findUnique.mockResolvedValue(PUBLIC_PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true })
|
||||
).resolves.toEqual({ project: PUBLIC_PROJECT_ROW, access });
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PUBLIC_PROJECT_ROW, undefined, {
|
||||
intent: 'view',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the manage intent down to the permission check', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isOwner: true, hasAccess: true, canEdit: true })
|
||||
);
|
||||
|
||||
await requireProjectAccessOrRedirect({
|
||||
projectId: PROJECT_ID,
|
||||
userId: USER_ID,
|
||||
intent: 'manage',
|
||||
});
|
||||
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, USER_ID, {
|
||||
intent: 'manage',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the session user when no id is passed', async () => {
|
||||
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isProjectMember: true, hasAccess: true })
|
||||
);
|
||||
|
||||
await requireProjectAccessOrRedirect({ projectId: PROJECT_ID });
|
||||
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
|
||||
intent: 'view',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireVideoProjectAccessOrRedirect', () => {
|
||||
const args = { projectId: PROJECT_ID, videoId: VIDEO_ID };
|
||||
|
||||
it('sends an anonymous caller to the login page before the video is read', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireVideoProjectAccessOrRedirect(args), LOGIN);
|
||||
expect(dbMock.video.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks the video up inside the project from the URL', async () => {
|
||||
// Without the projectId in the where clause, any video id would resolve
|
||||
// through any project the caller happens to be allowed to see.
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isOwner: true, hasAccess: true })
|
||||
);
|
||||
|
||||
await requireVideoProjectAccessOrRedirect({ ...args, userId: USER_ID });
|
||||
|
||||
expect(dbMock.video.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: VIDEO_ID, projectId: PROJECT_ID } })
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a 404 for a signed-in caller when the video is not in that project', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expectNotFound(requireVideoProjectAccessOrRedirect({ ...args, userId: USER_ID }));
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page rather than a 404 for a missing video', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page on a public route asking for manage rights', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, intent: 'manage', allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a signed-in stranger to the dashboard', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a read-only member to the dashboard when the page needs manage rights', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isProjectMember: true, hasAccess: true, canEdit: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID, intent: 'manage' }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('authorizes against the parent project and returns it alongside the video', async () => {
|
||||
const access = projectAccess({ isProjectMember: true, hasAccess: true });
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID })
|
||||
).resolves.toEqual({ video: VIDEO_ROW, project: PROJECT_ROW, access });
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
|
||||
intent: 'view',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an anonymous viewer watch a video in a public project when the route opts in', async () => {
|
||||
const publicVideo = { id: VIDEO_ID, project: PUBLIC_PROJECT_ROW };
|
||||
const access = projectAccess({ hasAccess: true });
|
||||
dbMock.video.findFirst.mockResolvedValue(publicVideo);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, allowPublicView: true })
|
||||
).resolves.toEqual({ video: publicVideo, project: PUBLIC_PROJECT_ROW, access });
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { resolveWorkspacePermissions } from '@/lib/auth';
|
||||
|
||||
// `@/lib/auth` reaches `@/lib/db`, which opens a pg Pool and registers process
|
||||
// signal handlers on import. resolveWorkspacePermissions touches no database.
|
||||
vi.mock('@/lib/db', () => ({ db: {}, default: {}, disconnectDb: vi.fn() }));
|
||||
|
||||
// The workspace half of the permission matrix. Until this file existed the
|
||||
// formulas were only ever reached through checkWorkspaceAccess(), which means
|
||||
// they were asserted on incidentally by whichever route a suite happened to
|
||||
// call. tests/unit/lib/project-access.test.ts does the same job for projects.
|
||||
//
|
||||
// Every case below states the expected verdict outright rather than deriving it
|
||||
// from the inputs, so a change to the formula cannot quietly change the
|
||||
// expectation with it.
|
||||
|
||||
// There is deliberately no separate 'anonymous' actor. resolveWorkspacePermissions
|
||||
// receives three booleans, not a user, and an anonymous caller and a signed-in
|
||||
// outsider set all three to false, so the two would be byte-identical inputs
|
||||
// running under names that imply a distinction this function cannot see. Telling
|
||||
// "no session" from "a session with no membership" is checkWorkspaceAccess()'s
|
||||
// job: it is the one that resolves a userId to membership rows before calling
|
||||
// here, and it is covered against the database in the api suites.
|
||||
type Actor = 'outsider' | 'member' | 'admin' | 'owner';
|
||||
|
||||
function inputsFor(actor: Actor, ownerBillingActive: boolean) {
|
||||
return {
|
||||
isOwner: actor === 'owner',
|
||||
isMember: actor === 'member' || actor === 'admin',
|
||||
isAdmin: actor === 'admin',
|
||||
ownerBillingActive,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveWorkspacePermissions, with the owner billing active', () => {
|
||||
const cases: Array<{
|
||||
actor: Actor;
|
||||
hasAccess: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}> = [
|
||||
{ actor: 'outsider', hasAccess: false, canEdit: false, canDelete: false },
|
||||
{ actor: 'member', hasAccess: true, canEdit: false, canDelete: false },
|
||||
{ actor: 'admin', hasAccess: true, canEdit: true, canDelete: false },
|
||||
{ actor: 'owner', hasAccess: true, canEdit: true, canDelete: true },
|
||||
];
|
||||
|
||||
for (const { actor, hasAccess, canEdit, canDelete } of cases) {
|
||||
it(`grants a ${actor} access=${hasAccess}, edit=${canEdit}, delete=${canDelete}`, () => {
|
||||
const result = resolveWorkspacePermissions(inputsFor(actor, true));
|
||||
|
||||
expect(result.hasAccess).toBe(hasAccess);
|
||||
expect(result.canEdit).toBe(canEdit);
|
||||
expect(result.canDelete).toBe(canDelete);
|
||||
});
|
||||
}
|
||||
|
||||
it('only the owner can delete, an admin cannot', () => {
|
||||
// Stated separately because it is the one rule that differs from the
|
||||
// project matrix, where a project admin does get canDelete through the
|
||||
// workspace-owner branch.
|
||||
expect(resolveWorkspacePermissions(inputsFor('admin', true)).canDelete).toBe(false);
|
||||
expect(resolveWorkspacePermissions(inputsFor('owner', true)).canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the membership flags it was handed, unchanged', () => {
|
||||
expect(resolveWorkspacePermissions(inputsFor('admin', true))).toEqual({
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
isAdmin: true,
|
||||
hasAccess: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
ownerBillingActive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkspacePermissions, with the owner billing lapsed', () => {
|
||||
// Billing is the outer gate: it revokes everything, including from the owner
|
||||
// of the workspace. A member who kept `hasAccess` here would keep reading a
|
||||
// workspace the account no longer pays for.
|
||||
for (const actor of ['outsider', 'member', 'admin', 'owner'] as const) {
|
||||
it(`refuses a ${actor} everything`, () => {
|
||||
const result = resolveWorkspacePermissions(inputsFor(actor, false));
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.canEdit).toBe(false);
|
||||
expect(result.canDelete).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it('still reports the membership flags, so a caller can tell "lapsed" from "not a member"', () => {
|
||||
const result = resolveWorkspacePermissions(inputsFor('owner', false));
|
||||
|
||||
expect(result.isOwner).toBe(true);
|
||||
expect(result.ownerBillingActive).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user