test: add unit, API, component and end-to-end test suites

The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
This commit is contained in:
yusufipk
2026-07-26 11:17:26 +07:00
parent 52b2c8d2a9
commit 1d099c68f2
101 changed files with 27625 additions and 122 deletions
+793
View File
@@ -0,0 +1,793 @@
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import {
GET as listApprovals,
POST as requestApproval,
} from '@/app/api/versions/[versionId]/approvals/route';
import { POST as decideApproval } from '@/app/api/approvals/[requestId]/decision/route';
import { POST as cancelApproval } from '@/app/api/approvals/[requestId]/cancel/route';
import { GET as listCandidates } from '@/app/api/projects/[projectId]/approval-candidates/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createApprovalRequest,
createUser,
seedVersion,
} from '../factories';
function approvalsUrl(versionId: string): string {
return `/api/versions/${versionId}/approvals`;
}
describe('GET /api/projects/[projectId]/approval-candidates', () => {
it('returns 401 without a session', async () => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
listCandidates,
apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
});
it('returns 403 for a COMMENTATOR, who cannot request approvals', async () => {
const scenario = await seedVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
listCandidates,
apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('lists the project owner, project members, workspace owner and workspace members once each', async () => {
const scenario = await seedVersion();
const projectMember = await createUser({ name: 'Bianca' });
const workspaceMember = await createUser({ name: 'Cleo' });
const both = await createUser({ name: 'Dana' });
await addProjectMember({ projectId: scenario.project.id, userId: projectMember.id });
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: workspaceMember.id });
await addProjectMember({ projectId: scenario.project.id, userId: both.id });
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: both.id });
signedInAs(scenario.owner);
const payload = await readData<{ candidates: Array<{ id: string }> }>(
await callRoute(
listCandidates,
apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`),
{ projectId: scenario.project.id }
)
);
const ids = payload.candidates.map((entry) => entry.id).sort();
expect(ids).toEqual([scenario.owner.id, projectMember.id, workspaceMember.id, both.id].sort());
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('POST /api/versions/[versionId]/approvals', () => {
it('returns 401 without a session', async () => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: ['x'] } }),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(401);
expect(await db.approvalRequest.count()).toBe(0);
});
it('returns 404 for an unknown version', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl('nope'), { body: { approverIds: ['x'] } }),
{ versionId: 'nope' }
);
expect(response.status).toBe(404);
});
it('returns 403 for a COMMENTATOR', async () => {
const scenario = await seedVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), {
body: { approverIds: [scenario.owner.id] },
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(403);
expect(await db.approvalRequest.count()).toBe(0);
});
it.each([
[{}, 'no approverIds at all'],
[{ approverIds: [] }, 'an empty approver list'],
[{ approverIds: 'not-an-array' }, 'a non-array approverIds'],
[{ approverIds: ['', ' '] }, 'blank approver ids'],
[{ approverIds: [42, null] }, 'non-string approver ids'],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), { body }),
{ versionId: scenario.version.id }
);
expect(response.status, label).toBe(400);
expect(await db.approvalRequest.count()).toBe(0);
});
it('rejects a message longer than 2000 characters', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
signedInAs(scenario.owner);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), {
body: { approverIds: [approver.id], message: 'x'.repeat(2001) },
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(400);
expect(await db.approvalRequest.count()).toBe(0);
});
it('refuses to let the requester approve their own request', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), {
body: { approverIds: [scenario.owner.id] },
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(400);
expect(await db.approvalRequest.count()).toBe(0);
});
// The candidate set is derived from project and workspace membership. Anyone
// outside it cannot be nominated, which is what stops an arbitrary user id
// being written into approval_decisions.
it('refuses an approver who is not a candidate for the project', async () => {
const scenario = await seedVersion();
const outsider = await createUser();
signedInAs(scenario.owner);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [outsider.id] } }),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(400);
expect(await db.approvalRequest.count()).toBe(0);
expect(await db.approvalDecision.count()).toBe(0);
});
it('creates the request with one PENDING decision per de-duplicated approver', async () => {
const scenario = await seedVersion();
const first = await createUser();
const second = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: first.id });
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: second.id });
signedInAs(scenario.owner);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), {
body: {
approverIds: [first.id, ` ${first.id} `, second.id],
message: ' please review ',
},
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(201);
const stored = await db.approvalRequest.findFirstOrThrow({ include: { decisions: true } });
expect(stored.status).toBe('PENDING');
expect(stored.requestedById).toBe(scenario.owner.id);
expect(stored.message).toBe('please review');
expect(stored.resolvedAt).toBeNull();
expect(stored.decisions).toHaveLength(2);
expect(stored.decisions.map((entry) => entry.approverId).sort()).toEqual(
[first.id, second.id].sort()
);
expect(stored.decisions.every((entry) => entry.status === 'PENDING')).toBe(true);
expect(stored.decisions.every((entry) => entry.respondedAt === null)).toBe(true);
});
it('returns 409 when a request is already pending on the version', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedInAs(scenario.owner);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [approver.id] } }),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(409);
expect(await db.approvalRequest.count()).toBe(1);
});
it('allows a new request once the previous one is resolved', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
status: 'REJECTED',
resolvedAt: new Date(),
});
signedInAs(scenario.owner);
const response = await callRoute(
requestApproval,
apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [approver.id] } }),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(201);
expect(await db.approvalRequest.count()).toBe(2);
});
});
describe('GET /api/versions/[versionId]/approvals', () => {
it('returns 403 for a signed-in stranger even on a PUBLIC project', async () => {
const scenario = await seedVersion({ visibility: 'PUBLIC' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(listApprovals, apiRequest(approvalsUrl(scenario.version.id)), {
versionId: scenario.version.id,
});
// hasMembership is required, not just hasAccess, so a public project does
// not expose its approval history to passers-by.
expect(response.status).toBe(403);
});
it('lists requests newest first for a COMMENTATOR member', async () => {
const scenario = await seedVersion();
const approver = await createUser();
const commentator = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
const older = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
status: 'CANCELED',
canceledAt: new Date(),
});
const newer = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedInAs(commentator);
const payload = await readData<{ requests: Array<{ id: string }> }>(
await callRoute(listApprovals, apiRequest(approvalsUrl(scenario.version.id)), {
versionId: scenario.version.id,
})
);
expect(payload.requests.map((entry) => entry.id)).toEqual([newer.id, older.id]);
});
});
describe('POST /api/approvals/[requestId]/decision', () => {
it('returns 401 without a session', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedOut();
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
expect(response.status).toBe(401);
expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
'PENDING'
);
});
it.each([['MAYBE'], [''], ['approved'], [null]])(
'returns 400 for the decision %s',
async (decision) => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedInAs(approver);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision } }),
{ requestId: request.id }
);
expect(response.status).toBe(400);
expect(
(await db.approvalDecision.findFirstOrThrow({ where: { requestId: request.id } })).status
).toBe('PENDING');
}
);
// The core negative case for this route: having access to the project is not
// the same as being nominated on the request.
it('returns 403 for a project member who is not an approver on the request', async () => {
const scenario = await seedVersion();
const approver = await createUser();
const bystander = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
await addProjectMember({ projectId: scenario.project.id, userId: bystander.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedInAs(bystander);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
expect(response.status).toBe(403);
expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
'PENDING'
);
expect(await db.approvalDecision.count({ where: { status: 'APPROVED' } })).toBe(0);
});
it('returns 403 for the project owner who requested it but is not an approver', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedInAs(scenario.owner);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
expect(response.status).toBe(403);
});
it('returns 403 for an approver who has lost project access', async () => {
const scenario = await seedVersion();
const approver = await createUser();
const membership = await addProjectMember({
projectId: scenario.project.id,
userId: approver.id,
});
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
await db.projectMember.delete({ where: { id: membership.id } });
signedInAs(approver);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
expect(response.status).toBe(403);
});
it('keeps the request PENDING while other approvers have not answered', async () => {
const scenario = await seedVersion();
const first = await createUser();
const second = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: first.id });
await addProjectMember({ projectId: scenario.project.id, userId: second.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [first.id, second.id],
});
signedInAs(first);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, {
body: { decision: 'APPROVED', note: ' looks good ' },
}),
{ requestId: request.id }
);
expect(response.status).toBe(200);
const stored = await db.approvalRequest.findUniqueOrThrow({
where: { id: request.id },
include: { decisions: true },
});
expect(stored.status).toBe('PENDING');
expect(stored.resolvedAt).toBeNull();
const mine = stored.decisions.find((entry) => entry.approverId === first.id)!;
expect(mine.status).toBe('APPROVED');
expect(mine.note).toBe('looks good');
expect(mine.respondedAt).toBeInstanceOf(Date);
expect(stored.decisions.find((entry) => entry.approverId === second.id)?.status).toBe(
'PENDING'
);
});
it('resolves the request as APPROVED once the last approver approves', async () => {
const scenario = await seedVersion();
const first = await createUser();
const second = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: first.id });
await addProjectMember({ projectId: scenario.project.id, userId: second.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [first.id, second.id],
});
signedInAs(first);
await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
signedInAs(second);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
expect(response.status).toBe(200);
const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } });
expect(stored.status).toBe('APPROVED');
expect(stored.resolvedAt).toBeInstanceOf(Date);
});
it('resolves the request as REJECTED on a single rejection', async () => {
const scenario = await seedVersion();
const first = await createUser();
const second = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: first.id });
await addProjectMember({ projectId: scenario.project.id, userId: second.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [first.id, second.id],
});
signedInAs(first);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'REJECTED' } }),
{ requestId: request.id }
);
expect(response.status).toBe(200);
const stored = await db.approvalRequest.findUniqueOrThrow({
where: { id: request.id },
include: { decisions: true },
});
expect(stored.status).toBe('REJECTED');
expect(stored.resolvedAt).toBeInstanceOf(Date);
// The second approver's row is left PENDING; the request is already decided.
expect(stored.decisions.find((entry) => entry.approverId === second.id)?.status).toBe(
'PENDING'
);
});
it('returns 409 when the same approver answers twice', async () => {
const scenario = await seedVersion();
const first = await createUser();
const second = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: first.id });
await addProjectMember({ projectId: scenario.project.id, userId: second.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [first.id, second.id],
});
signedInAs(first);
await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
const second_attempt = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'REJECTED' } }),
{ requestId: request.id }
);
expect(second_attempt.status).toBe(409);
expect(
(
await db.approvalDecision.findFirstOrThrow({
where: { requestId: request.id, approverId: first.id },
})
).status
).toBe('APPROVED');
});
it.each([['APPROVED'], ['REJECTED'], ['CANCELED']] as const)(
'returns 409 for a request already in the terminal status %s',
async (status) => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
status,
resolvedAt: new Date(),
});
signedInAs(approver);
const response = await callRoute(
decideApproval,
apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
{ requestId: request.id }
);
expect(response.status).toBe(409);
expect(
(await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status
).toBe(status);
}
);
});
describe('POST /api/approvals/[requestId]/cancel', () => {
it('returns 401 without a session', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedOut();
const response = await callRoute(
cancelApproval,
apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
{ requestId: request.id }
);
expect(response.status).toBe(401);
expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
'PENDING'
);
});
it('returns 404 for an unknown request', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
cancelApproval,
apiRequest('/api/approvals/nope/cancel', { method: 'POST', body: {} }),
{ requestId: 'nope' }
);
expect(response.status).toBe(404);
});
// The nominated approver is not the requester and has no canEdit, so it
// cannot cancel the request out from under the person who asked for it.
it('returns 403 for the nominated approver', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: approver.id,
role: 'COMMENTATOR',
});
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
signedInAs(approver);
const response = await callRoute(
cancelApproval,
apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
{ requestId: request.id }
);
expect(response.status).toBe(403);
expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
'PENDING'
);
});
it('returns 403 for a signed-in stranger', async () => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
});
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
cancelApproval,
apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
{ requestId: request.id }
);
expect(response.status).toBe(403);
});
it('lets the requester cancel and records who did it', async () => {
const scenario = await seedVersion();
const requester = await createUser();
const approver = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: requester.id,
role: 'ADMIN',
});
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: requester.id,
approverIds: [approver.id],
});
signedInAs(requester);
const response = await callRoute(
cancelApproval,
apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
{ requestId: request.id }
);
expect(response.status).toBe(200);
const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } });
expect(stored.status).toBe('CANCELED');
expect(stored.canceledById).toBe(requester.id);
expect(stored.canceledAt).toBeInstanceOf(Date);
});
it('lets a project ADMIN cancel a request somebody else made', async () => {
const scenario = await seedVersion();
const requester = await createUser();
const admin = await createUser();
const approver = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: requester.id,
role: 'ADMIN',
});
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: requester.id,
approverIds: [approver.id],
});
signedInAs(admin);
const response = await callRoute(
cancelApproval,
apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
{ requestId: request.id }
);
expect(response.status).toBe(200);
expect(
(await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).canceledById
).toBe(admin.id);
});
it.each([['APPROVED'], ['REJECTED'], ['CANCELED']] as const)(
'returns 409 for a request already %s',
async (status) => {
const scenario = await seedVersion();
const approver = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
const request = await createApprovalRequest({
versionId: scenario.version.id,
requestedById: scenario.owner.id,
approverIds: [approver.id],
status,
resolvedAt: new Date(),
});
signedInAs(scenario.owner);
const response = await callRoute(
cancelApproval,
apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
{ requestId: request.id }
);
expect(response.status).toBe(409);
const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } });
expect(stored.status).toBe(status);
expect(stored.canceledById).toBeNull();
}
);
});
+669
View File
@@ -0,0 +1,669 @@
// Authorization tests for the /api/videos/[videoId]/assets/* family, from callers
// who are signed in but not entitled.
//
// Every route in this family authorizes through one helper,
// `getVideoAssetAccessContext()` in lib/video-assets.ts, and then reads one of
// three flags off it: `hasViewAccess` to list, `canUploadAssets` to write, and
// `canDownloadAssets` to export. Before this file the only thing standing behind
// those flags was the anonymous sweep in tests/api/auth-matrix.test.ts, so
// collapsing all three onto `hasViewAccess`, or returning a context that is
// simply `{ hasViewAccess: true, canUploadAssets: true, ... }` for any signed-in
// caller, would not have failed a single test in the suite.
//
// Two details make these cases land on the guard rather than short of it.
//
// - Each route checks the access context *before* it parses the body. So an
// unauthorized caller gets 403 and an authorized caller sending the same
// payload gets a 400 from the validation underneath. The positive controls
// below deliberately stop on that 400: it is a status no unauthorized caller
// can reach, which is what makes the 403 next to it mean something.
//
// - The assets are YOUTUBE-provider rows. Deleting an R2 or Bunny asset sends
// the handler off to object storage, and downloading one proxies the bytes;
// a YouTube asset exercises the identical authorization path with no network
// underneath it.
import { describe, expect, it } from 'vitest';
import type { Project, User, Video, VideoAsset, Workspace } from '@prisma/client';
import { db } from '@/lib/db';
import { GET as listAssets, POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
import { DELETE as deleteAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/route';
import { GET as downloadAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/download/route';
import { POST as initAssetBunnyUpload } from '@/app/api/videos/[videoId]/assets/bunny-init/route';
import { POST as initAssetR2Upload } from '@/app/api/videos/[videoId]/assets/r2-init/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createUser,
createVideo,
createVideoAsset,
nextSeq,
seedProject,
} from '../factories';
const SEEDED_ASSET_NAME = 'Seeded b-roll';
interface AssetFixture {
owner: User;
workspace: Workspace;
project: Project;
video: Video;
/** Uploaded by the project owner, so a COMMENTATOR is not its author. */
asset: VideoAsset;
}
async function seedAsset(
input: { allowDownloads: boolean; ownerUser?: User } = { allowDownloads: false }
): Promise<AssetFixture> {
const { owner, workspace, project } = await seedProject({
ownerUser: input.ownerUser,
visibility: 'PRIVATE',
allowDownloads: input.allowDownloads,
});
const video = await createVideo({ projectId: project.id, title: 'Video with assets' });
const asset = await createVideoAsset({
videoId: video.id,
billedUserId: owner.id,
kind: 'VIDEO',
provider: 'YOUTUBE',
displayName: SEEDED_ASSET_NAME,
sourceUrl: `https://www.youtube.com/watch?v=asset${nextSeq()}`,
providerVideoId: `asset-provider-${nextSeq()}`,
uploadedByUserId: owner.id,
});
return { owner, workspace, project, video, asset };
}
function assetsUrl(videoId: string): string {
return `/api/videos/${videoId}/assets`;
}
function assetUrl(videoId: string, assetId: string): string {
return `${assetsUrl(videoId)}/${assetId}`;
}
// ---------------------------------------------------------------------------
// GET /api/videos/[videoId]/assets
// ---------------------------------------------------------------------------
describe('GET /api/videos/[videoId]/assets', () => {
it('returns 403 to a signed-in stranger with their own unrelated workspace', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
videoId: fixture.video.id,
});
expect(response.status).toBe(403);
});
it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: true, ownerUser: expiredOwner });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
videoId: fixture.video.id,
});
expect(response.status).toBe(403);
});
it('lets a project COMMENTATOR list the assets', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
videoId: fixture.video.id,
});
expect(response.status).toBe(200);
const payload = await readData<{ assets: Array<{ id: string }> }>(response);
expect(payload.assets.map((asset) => asset.id)).toEqual([fixture.asset.id]);
});
});
// ---------------------------------------------------------------------------
// POST /api/videos/[videoId]/assets
// ---------------------------------------------------------------------------
// `canUploadAssets` is intentionally generous: a COMMENTATOR is meant to be able
// to attach a reference clip. Generous is not the same as open, and the cases
// below are the difference.
describe('POST /api/videos/[videoId]/assets', () => {
it('returns 403 to a signed-in stranger and writes no asset', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), {
body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count()).toBe(1);
});
it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), {
body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count()).toBe(1);
});
// The IDOR shape: a caller who legitimately uploads assets to their own video,
// aiming the same request at a video id out of another workspace.
it('returns 403 for a video id belonging to another workspace', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(theirs.video.id), {
body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
}),
{ videoId: theirs.video.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { videoId: theirs.video.id } })).toBe(1);
});
// The positive control. The access check runs before the body is parsed, so an
// authorized COMMENTATOR sending a deliberately bogus provider gets the 400
// from the validation underneath. 400 is a status the three refusals above
// cannot produce, which is what proves they came from the guard.
it('gets a project COMMENTATOR past the access check and onto body validation', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Invalid provider');
expect(await db.videoAsset.count()).toBe(1);
});
// And the same probe from the stranger, to show the ordering is real: identical
// body, identical URL, and the guard answers first.
it('still returns 403 to a stranger sending the same invalid body', async () => {
const fixture = await seedAsset();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// DELETE /api/videos/[videoId]/assets/[assetId]
// ---------------------------------------------------------------------------
// Two gates in sequence: `canUploadAssets` to be in the room at all, then
// `canDeleteAssetForViewer` which lets a COMMENTATOR remove only what they
// uploaded themselves. Both need their own negative case, because collapsing the
// second one is invisible from outside unless a test actually seeds an asset that
// belongs to somebody else.
describe('DELETE /api/videos/[videoId]/assets/[assetId]', () => {
it('returns 403 to a signed-in stranger and keeps the asset', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
// The second gate. This caller is a legitimate member who may upload assets of
// their own; what they may not do is delete the owner's.
it("returns 403 when a project COMMENTATOR deletes somebody else's asset", async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await readError(response)).toContain('only delete assets you uploaded');
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
it("returns 403 when a workspace COMMENTATOR deletes the owner's asset", async () => {
const fixture = await seedAsset();
const workspaceCommentator = await createUser();
await addWorkspaceMember({
workspaceId: fixture.workspace.id,
userId: workspaceCommentator.id,
role: 'COMMENTATOR',
});
signedInAs(workspaceCommentator);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
it('returns 403 to the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
// Identifier substitution against a route the caller does legitimately reach:
// their own videoId in the path, somebody else's assetId in the query. The
// lookup pairs the two, so it misses.
it('returns 404 for a foreign asset id pasted onto my own video', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(mine.video.id, theirs.asset.id), { method: 'DELETE' }),
{ videoId: mine.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(404);
expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1);
expect(await db.videoAsset.count({ where: { id: mine.asset.id } })).toBe(1);
});
// The matching pair with both foreign ids, which is the request an attacker who
// has read an id out of a shared link would actually send. Here the row is
// found, so the refusal has to come from the access context.
it('returns 403 for a foreign asset reached through its own foreign video id', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(theirs.video.id, theirs.asset.id), { method: 'DELETE' }),
{ videoId: theirs.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1);
});
it('lets the project owner delete the asset', async () => {
const fixture = await seedAsset();
signedInAs(fixture.owner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(200);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0);
});
// The positive control for the second gate specifically: same role, same route,
// and the only thing that changed is who uploaded the row.
it('lets a project COMMENTATOR delete an asset they uploaded themselves', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
const own = await createVideoAsset({
videoId: fixture.video.id,
billedUserId: fixture.owner.id,
kind: 'VIDEO',
provider: 'YOUTUBE',
displayName: 'Uploaded by the commentator',
sourceUrl: `https://www.youtube.com/watch?v=own${nextSeq()}`,
uploadedByUserId: commentator.id,
});
signedInAs(commentator);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, own.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: own.id }
);
expect(response.status).toBe(200);
expect(await db.videoAsset.count({ where: { id: own.id } })).toBe(0);
// The owner's asset was never in scope and is still there.
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
it("lets a project ADMIN delete the owner's asset", async () => {
const fixture = await seedAsset();
const admin = await createUser();
await addProjectMember({ projectId: fixture.project.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(200);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0);
});
});
// ---------------------------------------------------------------------------
// GET /api/videos/[videoId]/assets/[assetId]/download
// ---------------------------------------------------------------------------
// Two refusals with two different messages: `hasViewAccess` for people who should
// not see the video at all, and `canDownloadAssets` for members of a project whose
// owner has turned exports off. Both are pinned, because merging them would look
// like a tidy-up and would quietly hand the files to every viewer.
describe('GET /api/videos/[videoId]/assets/[assetId]/download', () => {
it('returns 403 to a signed-in stranger', async () => {
const fixture = await seedAsset({ allowDownloads: true });
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await readError(response)).toContain('Access denied');
});
it('returns 403 to a project COMMENTATOR when downloads are disabled', async () => {
const fixture = await seedAsset({ allowDownloads: false });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await readError(response)).toContain('Downloads are disabled');
});
// Positive control: same COMMENTATOR, same asset, allowDownloads flipped on.
// The request now clears both gates and stops on the provider check, a 400 that
// neither refusal above can produce.
it('gets the same COMMENTATOR past both gates once allowDownloads is on', async () => {
const fixture = await seedAsset({ allowDownloads: true });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('YouTube assets cannot be downloaded');
});
it('gets the owner past both gates even when allowDownloads is off', async () => {
const fixture = await seedAsset({ allowDownloads: false });
signedInAs(fixture.owner);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('YouTube assets cannot be downloaded');
});
it('returns 404 for a foreign asset id pasted onto my own video', async () => {
const mine = await seedAsset({ allowDownloads: true });
const theirs = await seedAsset({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(mine.video.id, theirs.asset.id)}/download`),
{ videoId: mine.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(404);
});
it('returns 403 for a foreign asset reached through its own foreign video id', async () => {
const mine = await seedAsset({ allowDownloads: true });
const theirs = await seedAsset({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(theirs.video.id, theirs.asset.id)}/download`),
{ videoId: theirs.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// The two upload-init routes
// ---------------------------------------------------------------------------
// Both hand out an upload credential, so a caller who gets through them can spend
// the workspace owner's storage quota. Direct uploads are unconfigured in the test
// environment, which is what gives each of these a positive control that stops one
// step past the guard without touching a provider.
describe('POST /api/videos/[videoId]/assets/r2-init', () => {
it('returns 403 to a signed-in stranger and reserves nothing', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
initAssetR2Upload,
apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
expect(await db.uploadReservation.count()).toBe(0);
});
it('returns 403 for a video id belonging to another workspace', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
initAssetR2Upload,
apiRequest(`${assetsUrl(theirs.video.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ videoId: theirs.video.id }
);
expect(response.status).toBe(403);
expect(await db.uploadReservation.count()).toBe(0);
});
it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initAssetR2Upload,
apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('S3 video uploads are disabled');
expect(await db.uploadReservation.count()).toBe(0);
});
});
describe('POST /api/videos/[videoId]/assets/bunny-init', () => {
it('returns 403 to a signed-in stranger', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
initAssetBunnyUpload,
apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
});
it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initAssetBunnyUpload,
apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
});
it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initAssetBunnyUpload,
apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Direct uploads are disabled');
});
});
+820
View File
@@ -0,0 +1,820 @@
// A sweep over every route module under app/api asserting that an
// unauthenticated caller can never reach a 2xx.
//
// Three properties make this more than a smoke test:
//
// 1. The routes are enumerated by walking app/api on disk and cross-checked
// against the table below. Add a route and this file fails until someone
// classifies it as guarded or public. That is the point: the classification
// is a reviewable diff, not an omission nobody notices.
//
// 2. Every id in the table is a real row, seeded per test. A matrix built on
// made-up ids passes even with the authorization deleted, because the route
// 404s before it ever checks anything. Here the project exists, the video
// exists, the comment exists, and the only reason the call fails is the
// access check.
//
// 3. A 500 counts as a failure. Rejecting an anonymous caller by crashing is
// not rejecting it.
//
// The project is PRIVATE and no share-session cookie is sent, so nothing here
// is legitimately reachable without a session.
import fs from 'node:fs';
import path from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { REPO_ROOT } from '../helpers/env';
import { apiRequest, callRoute, type RouteHandler } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createApprovalRequest,
createComment,
createCommentTag,
createProject,
createShareLink,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
createInvitation,
} from '../factories';
import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route';
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
import * as billingPortalRoute from '@/app/api/billing/portal/route';
import * as billingRoute from '@/app/api/billing/route';
import * as commentRoute from '@/app/api/comments/[commentId]/route';
import * as feedbackRoute from '@/app/api/feedback/route';
import * as feedbackUploadRoute from '@/app/api/feedback/upload/route';
import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route';
import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route';
import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route';
import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route';
import * as projectMemberRoute from '@/app/api/projects/[projectId]/members/[memberId]/route';
import * as projectMembersRoute from '@/app/api/projects/[projectId]/members/route';
import * as projectRoute from '@/app/api/projects/[projectId]/route';
import * as projectTagsRoute from '@/app/api/projects/[projectId]/tags/route';
import * as projectTagRoute from '@/app/api/projects/[projectId]/tags/[tagId]/route';
import * as videosBulkDeleteRoute from '@/app/api/projects/[projectId]/videos/bulk-delete/route';
import * as videosBunnyInitRoute from '@/app/api/projects/[projectId]/videos/bunny-init/route';
import * as videosMoveRoute from '@/app/api/projects/[projectId]/videos/move/route';
import * as videosR2CompleteRoute from '@/app/api/projects/[projectId]/videos/r2-complete/route';
import * as videosR2InitRoute from '@/app/api/projects/[projectId]/videos/r2-init/route';
import * as projectVideosRoute from '@/app/api/projects/[projectId]/videos/route';
import * as projectVideoRoute from '@/app/api/projects/[projectId]/videos/[videoId]/route';
import * as videoShareRoute from '@/app/api/projects/[projectId]/videos/[videoId]/share/route';
import * as videoVersionsRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/route';
import * as videoVersionRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route';
import * as projectsRoute from '@/app/api/projects/route';
import * as searchRoute from '@/app/api/search/route';
import * as settingsNotificationsRoute from '@/app/api/settings/notifications/route';
import * as settingsStorageRoute from '@/app/api/settings/storage/route';
import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route';
import * as uploadAudioRoute from '@/app/api/upload/audio/route';
import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
import * as uploadImageRoute from '@/app/api/upload/image/route';
import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route';
import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route';
import * as versionCommentsRoute from '@/app/api/versions/[versionId]/comments/route';
import * as versionDownloadRoute from '@/app/api/versions/[versionId]/download/route';
import * as assetDownloadRoute from '@/app/api/videos/[videoId]/assets/[assetId]/download/route';
import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route';
import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route';
import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route';
import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route';
import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route';
import * as watchRoute from '@/app/api/watch/[videoId]/route';
import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route';
import * as workspacesRoute from '@/app/api/workspaces/route';
import * as workspaceInvitationRoute from '@/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route';
import * as workspaceMemberRoute from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
import * as workspaceMembersRoute from '@/app/api/workspaces/[workspaceId]/members/route';
import * as workspaceRoute from '@/app/api/workspaces/[workspaceId]/route';
// ---------------------------------------------------------------------------
// The count guard
// ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
const EXPECTED_ROUTE_MODULE_COUNT = 60;
/**
* Routes that are public by design, and why. Everything else must reject an
* anonymous caller. Moving a file into this set is the visible diff that says
* "this endpoint is now reachable without a session".
*/
const PUBLIC_ROUTES: ReadonlyMap<string, string> = new Map([
[
'auth/[...nextauth]/route.ts',
// The NextAuth handler itself: sign-in, callback and CSRF endpoints. It has
// to be reachable by a caller who has no session yet, by definition.
'NextAuth sign-in/callback handler',
],
[
'auth/register/route.ts',
// Account creation. Gated by OPENFRAME_REQUIRE_INVITE_CODE plus an IP rate
// limit rather than by a session. Covered in tests/api/register.test.ts.
'account creation, gated by the invite code',
],
[
'auth/verify-email/route.ts',
// Reached by clicking a link in an email, before the user can sign in.
// Authenticated by the one-time token in the query string.
'email verification link, authenticated by a single-use token',
],
[
'auth/verify-email/resend/route.ts',
// A user who cannot sign in because they are unverified has no session to
// present. Rate limited by IP, and answers identically for unknown emails
// so it cannot be used to enumerate accounts.
'resend of the verification email, for users who cannot sign in yet',
],
[
'stripe/webhook/route.ts',
// Called by Stripe, not by a browser. Authenticated by the HMAC signature
// in the stripe-signature header. Covered in
// tests/api/stripe-webhook.test.ts, including the rejection of a bad one.
'Stripe webhook, authenticated by an HMAC signature',
],
]);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png';
const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
interface Fixtures {
userId: string;
workspaceId: string;
workspaceMemberId: string;
workspaceInvitationId: string;
projectId: string;
projectMemberId: string;
projectInvitationId: string;
tagId: string;
videoId: string;
versionId: string;
commentId: string;
assetId: string;
approvalRequestId: string;
feedbackId: string;
}
async function seedFixtures(): Promise<Fixtures> {
const owner = await createUser();
const collaborator = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const workspaceMember = await addWorkspaceMember({
workspaceId: workspace.id,
userId: collaborator.id,
});
const workspaceInvitation = await createInvitation({
invitedById: owner.id,
scope: 'WORKSPACE',
workspaceId: workspace.id,
});
// PRIVATE on purpose. A PUBLIC project grants anonymous read access through
// computeProjectAccess(), which would make several of the GET routes return
// 200 for entirely legitimate reasons and hide the ones that should not.
const project = await createProject({
ownerId: owner.id,
workspaceId: workspace.id,
visibility: 'PRIVATE',
allowDownloads: true,
});
const projectMember = await addProjectMember({
projectId: project.id,
userId: collaborator.id,
});
const projectInvitation = await createInvitation({
invitedById: owner.id,
scope: 'PROJECT',
projectId: project.id,
});
const tag = await createCommentTag({ projectId: project.id });
const video = await createVideo({ projectId: project.id });
const version = await createVersion({
videoParentId: video.id,
providerId: 'r2',
providerVideoId: `videos/${VIDEO_FILENAME}`,
originalUrl: `/api/upload/video/${VIDEO_FILENAME}`,
sizeBytes: BigInt(1024),
});
const comment = await createComment({ versionId: version.id, authorId: owner.id });
const asset = await createVideoAsset({
videoId: video.id,
billedUserId: owner.id,
sourceUrl: `/api/upload/image/${IMAGE_FILENAME}`,
});
// A second asset so /api/upload/audio/[filename] resolves to a real row too.
await createVideoAsset({
videoId: video.id,
billedUserId: owner.id,
kind: 'AUDIO',
provider: 'R2_AUDIO',
sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`,
});
await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' });
const approvalRequest = await createApprovalRequest({
versionId: version.id,
requestedById: owner.id,
approverIds: [collaborator.id],
});
const feedback = await db.userFeedback.create({
data: {
userId: owner.id,
type: 'FEEDBACK',
title: 'Matrix fixture feedback',
message: 'Seeded so the admin delete route has a real row to refuse.',
},
});
return {
userId: owner.id,
workspaceId: workspace.id,
workspaceMemberId: workspaceMember.id,
workspaceInvitationId: workspaceInvitation.id,
projectId: project.id,
projectMemberId: projectMember.id,
projectInvitationId: projectInvitation.id,
tagId: tag.id,
videoId: video.id,
versionId: version.id,
commentId: comment.id,
assetId: asset.id,
approvalRequestId: approvalRequest.id,
feedbackId: feedback.id,
};
}
// ---------------------------------------------------------------------------
// The table
// ---------------------------------------------------------------------------
type ParamRecord = Record<string, string | string[]>;
interface RouteCase {
/** Path of the route module relative to app/api. */
file: string;
module: Record<string, unknown>;
url: (fixtures: Fixtures) => string;
params?: (fixtures: Fixtures) => ParamRecord;
/** JSON body for the non-GET methods. A valid `{}` by default, so that a
* route which parses before authorizing rejects rather than crashes. */
body?: unknown;
/** Replaces `body`, for the routes that read request.formData(). */
rawBody?: (fixtures: Fixtures) => BodyInit;
headers?: Record<string, string>;
}
/**
* A multipart body that gets past the shape checks in the two upload routes and
* reaches their access check.
*
* This is not decoration. Both routes validate the request before they
* authorize: /api/upload/image bails with "Missing Content-Length header" at its
* first line, and /api/upload/audio bails with "No audio file provided" before
* checkProjectAccess() is ever called. An empty FormData therefore produced a
* 400 for an anonymous caller *and* an identical 400 for the workspace owner,
* which means the assertion below held with the authorization deleted. Sending a
* real file and a real videoId is what makes the 403 come from the access check.
*/
function uploadForm(field: 'image' | 'audio', fixtures: Fixtures): FormData {
const form = new FormData();
form.append(field, new File([new Uint8Array([1, 2, 3, 4])], `anon.${field}`));
form.append('videoId', fixtures.videoId);
return form;
}
const ROUTE_CASES: readonly RouteCase[] = [
{
file: 'admin/feedback/[feedbackId]/route.ts',
module: adminFeedbackRoute,
url: (f) => `/api/admin/feedback/${f.feedbackId}`,
params: (f) => ({ feedbackId: f.feedbackId }),
},
{
file: 'admin/stats/refresh-r2/route.ts',
module: adminRefreshR2Route,
url: () => '/api/admin/stats/refresh-r2',
},
{
file: 'approvals/[requestId]/cancel/route.ts',
module: approvalCancelRoute,
url: (f) => `/api/approvals/${f.approvalRequestId}/cancel`,
params: (f) => ({ requestId: f.approvalRequestId }),
},
{
file: 'approvals/[requestId]/decision/route.ts',
module: approvalDecisionRoute,
url: (f) => `/api/approvals/${f.approvalRequestId}/decision`,
params: (f) => ({ requestId: f.approvalRequestId }),
body: { decision: 'APPROVED' },
},
{
file: 'billing/checkout/route.ts',
module: billingCheckoutRoute,
url: () => '/api/billing/checkout',
headers: { origin: 'http://localhost:3000' },
},
{
file: 'billing/portal/route.ts',
module: billingPortalRoute,
url: () => '/api/billing/portal',
headers: { origin: 'http://localhost:3000' },
},
{ file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' },
{
file: 'comments/[commentId]/route.ts',
module: commentRoute,
url: (f) => `/api/comments/${f.commentId}`,
params: (f) => ({ commentId: f.commentId }),
body: { content: 'edited by an anonymous caller' },
},
{
file: 'feedback/route.ts',
module: feedbackRoute,
url: () => '/api/feedback',
body: { type: 'FEEDBACK', title: 'anon', message: 'anon' },
},
{
file: 'feedback/upload/route.ts',
module: feedbackUploadRoute,
url: () => '/api/feedback/upload',
rawBody: () => new FormData(),
},
{
file: 'onboarding/complete/route.ts',
module: onboardingCompleteRoute,
url: () => '/api/onboarding/complete',
},
{
file: 'projects/[projectId]/approval-candidates/route.ts',
module: approvalCandidatesRoute,
url: (f) => `/api/projects/${f.projectId}/approval-candidates`,
params: (f) => ({ projectId: f.projectId }),
},
{
file: 'projects/[projectId]/download/route.ts',
module: projectDownloadRoute,
url: (f) => `/api/projects/${f.projectId}/download`,
params: (f) => ({ projectId: f.projectId }),
},
{
file: 'projects/[projectId]/members/invitations/[invitationId]/route.ts',
module: projectInvitationRoute,
url: (f) => `/api/projects/${f.projectId}/members/invitations/${f.projectInvitationId}`,
params: (f) => ({ projectId: f.projectId, invitationId: f.projectInvitationId }),
},
{
file: 'projects/[projectId]/members/[memberId]/route.ts',
module: projectMemberRoute,
url: (f) => `/api/projects/${f.projectId}/members/${f.projectMemberId}`,
params: (f) => ({ projectId: f.projectId, memberId: f.projectMemberId }),
body: { role: 'ADMIN' },
},
{
file: 'projects/[projectId]/members/route.ts',
module: projectMembersRoute,
url: (f) => `/api/projects/${f.projectId}/members`,
params: (f) => ({ projectId: f.projectId }),
body: { email: '[email protected]', role: 'ADMIN' },
},
{
file: 'projects/[projectId]/route.ts',
module: projectRoute,
url: (f) => `/api/projects/${f.projectId}`,
params: (f) => ({ projectId: f.projectId }),
body: { name: 'renamed by an anonymous caller' },
},
{
file: 'projects/[projectId]/tags/route.ts',
module: projectTagsRoute,
url: (f) => `/api/projects/${f.projectId}/tags`,
params: (f) => ({ projectId: f.projectId }),
body: { name: 'Anon', color: '#ff0000' },
},
{
file: 'projects/[projectId]/tags/[tagId]/route.ts',
module: projectTagRoute,
url: (f) => `/api/projects/${f.projectId}/tags/${f.tagId}`,
params: (f) => ({ projectId: f.projectId, tagId: f.tagId }),
body: { name: 'Anon' },
},
{
file: 'projects/[projectId]/videos/bulk-delete/route.ts',
module: videosBulkDeleteRoute,
url: (f) => `/api/projects/${f.projectId}/videos/bulk-delete`,
params: (f) => ({ projectId: f.projectId }),
body: { videoIds: ['does-not-matter'] },
},
{
file: 'projects/[projectId]/videos/bunny-init/route.ts',
module: videosBunnyInitRoute,
url: (f) => `/api/projects/${f.projectId}/videos/bunny-init`,
params: (f) => ({ projectId: f.projectId }),
body: { title: 'anon' },
},
{
file: 'projects/[projectId]/videos/move/route.ts',
module: videosMoveRoute,
url: (f) => `/api/projects/${f.projectId}/videos/move`,
params: (f) => ({ projectId: f.projectId }),
body: { videoIds: ['x'], targetProjectId: 'y' },
},
{
file: 'projects/[projectId]/videos/r2-complete/route.ts',
module: videosR2CompleteRoute,
url: (f) => `/api/projects/${f.projectId}/videos/r2-complete`,
params: (f) => ({ projectId: f.projectId }),
body: { objectKey: 'x', uploadToken: 'y' },
},
{
file: 'projects/[projectId]/videos/r2-init/route.ts',
module: videosR2InitRoute,
url: (f) => `/api/projects/${f.projectId}/videos/r2-init`,
params: (f) => ({ projectId: f.projectId }),
body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
},
{
file: 'projects/[projectId]/videos/route.ts',
module: projectVideosRoute,
url: (f) => `/api/projects/${f.projectId}/videos`,
params: (f) => ({ projectId: f.projectId }),
body: { title: 'anon', videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
},
{
file: 'projects/[projectId]/videos/[videoId]/route.ts',
module: projectVideoRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
body: { title: 'renamed by an anonymous caller' },
},
{
file: 'projects/[projectId]/videos/[videoId]/share/route.ts',
module: videoShareRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/share`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
body: { allowGuests: true },
},
{
file: 'projects/[projectId]/videos/[videoId]/versions/route.ts',
module: videoVersionsRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
body: { videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
},
{
file: 'projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts',
module: videoVersionRoute,
url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions/${f.versionId}`,
params: (f) => ({ projectId: f.projectId, videoId: f.videoId, versionId: f.versionId }),
body: { versionLabel: 'anon' },
},
{
file: 'projects/route.ts',
module: projectsRoute,
url: () => '/api/projects',
body: { name: 'anon project', workspaceId: 'anything' },
},
{ file: 'search/route.ts', module: searchRoute, url: () => '/api/search?q=test' },
{
file: 'settings/notifications/route.ts',
module: settingsNotificationsRoute,
url: () => '/api/settings/notifications',
body: { emailEnabled: true },
},
{
file: 'settings/storage/route.ts',
module: settingsStorageRoute,
url: () => '/api/settings/storage',
},
{
file: 'upload/audio/[filename]/route.ts',
module: uploadAudioFileRoute,
url: () => `/api/upload/audio/${AUDIO_FILENAME}`,
params: () => ({ filename: AUDIO_FILENAME }),
},
{
file: 'upload/audio/route.ts',
module: uploadAudioRoute,
url: () => '/api/upload/audio',
rawBody: (f) => uploadForm('audio', f),
},
{
file: 'upload/image/[filename]/route.ts',
module: uploadImageFileRoute,
url: () => `/api/upload/image/${IMAGE_FILENAME}`,
params: () => ({ filename: IMAGE_FILENAME }),
},
{
file: 'upload/image/route.ts',
module: uploadImageRoute,
url: () => '/api/upload/image',
rawBody: (f) => uploadForm('image', f),
// The route rejects a missing Content-Length before it does anything else,
// and constructing a Request from a FormData does not set one.
headers: { 'content-length': '2048' },
},
{
file: 'upload/video/[filename]/route.ts',
module: uploadVideoFileRoute,
url: () => `/api/upload/video/${VIDEO_FILENAME}`,
params: () => ({ filename: VIDEO_FILENAME }),
},
{
file: 'versions/[versionId]/approvals/route.ts',
module: versionApprovalsRoute,
url: (f) => `/api/versions/${f.versionId}/approvals`,
params: (f) => ({ versionId: f.versionId }),
body: { approverIds: ['someone'] },
},
{
file: 'versions/[versionId]/comments/export/route.ts',
module: commentsExportRoute,
url: (f) => `/api/versions/${f.versionId}/comments/export`,
params: (f) => ({ versionId: f.versionId }),
},
{
file: 'versions/[versionId]/comments/route.ts',
module: versionCommentsRoute,
url: (f) => `/api/versions/${f.versionId}/comments`,
params: (f) => ({ versionId: f.versionId }),
body: { content: 'anonymous comment', timestamp: 1, guestName: 'Anon' },
},
{
file: 'versions/[versionId]/download/route.ts',
module: versionDownloadRoute,
url: (f) => `/api/versions/${f.versionId}/download`,
params: (f) => ({ versionId: f.versionId }),
},
{
file: 'videos/[videoId]/assets/[assetId]/download/route.ts',
module: assetDownloadRoute,
url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}/download`,
params: (f) => ({ videoId: f.videoId, assetId: f.assetId }),
},
{
file: 'videos/[videoId]/assets/[assetId]/route.ts',
module: assetRoute,
url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}`,
params: (f) => ({ videoId: f.videoId, assetId: f.assetId }),
},
{
file: 'videos/[videoId]/assets/bunny-init/route.ts',
module: assetsBunnyInitRoute,
url: (f) => `/api/videos/${f.videoId}/assets/bunny-init`,
params: (f) => ({ videoId: f.videoId }),
// 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.
body: { fileName: 'a.mp4' },
},
{
file: 'videos/[videoId]/assets/r2-init/route.ts',
module: assetsR2InitRoute,
url: (f) => `/api/videos/${f.videoId}/assets/r2-init`,
params: (f) => ({ videoId: f.videoId }),
body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
},
{
file: 'videos/[videoId]/assets/route.ts',
module: assetsRoute,
url: (f) => `/api/videos/${f.videoId}/assets`,
params: (f) => ({ videoId: f.videoId }),
// The body 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.
body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` },
},
{
file: 'watch/[videoId]/progress/route.ts',
module: watchProgressRoute,
url: (f) => `/api/watch/${f.videoId}/progress`,
params: (f) => ({ videoId: f.videoId }),
body: { progress: 10, duration: 100 },
},
{
file: 'watch/[videoId]/route.ts',
module: watchRoute,
url: (f) => `/api/watch/${f.videoId}`,
params: (f) => ({ videoId: f.videoId }),
},
{
file: 'watch/[videoId]/upload-token/route.ts',
module: watchUploadTokenRoute,
url: (f) => `/api/watch/${f.videoId}/upload-token`,
params: (f) => ({ videoId: f.videoId }),
body: { intent: 'image' },
headers: { origin: 'http://localhost:3000' },
},
{
file: 'workspaces/route.ts',
module: workspacesRoute,
url: () => '/api/workspaces',
body: { name: 'anon workspace' },
},
{
file: 'workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts',
module: workspaceInvitationRoute,
url: (f) => `/api/workspaces/${f.workspaceId}/members/invitations/${f.workspaceInvitationId}`,
params: (f) => ({ workspaceId: f.workspaceId, invitationId: f.workspaceInvitationId }),
},
{
file: 'workspaces/[workspaceId]/members/[memberId]/route.ts',
module: workspaceMemberRoute,
url: (f) => `/api/workspaces/${f.workspaceId}/members/${f.workspaceMemberId}`,
params: (f) => ({ workspaceId: f.workspaceId, memberId: f.workspaceMemberId }),
body: { role: 'ADMIN' },
},
{
file: 'workspaces/[workspaceId]/members/route.ts',
module: workspaceMembersRoute,
url: (f) => `/api/workspaces/${f.workspaceId}/members`,
params: (f) => ({ workspaceId: f.workspaceId }),
body: { email: '[email protected]', role: 'ADMIN' },
},
{
file: 'workspaces/[workspaceId]/route.ts',
module: workspaceRoute,
url: (f) => `/api/workspaces/${f.workspaceId}`,
params: (f) => ({ workspaceId: f.workspaceId }),
body: { name: 'renamed by an anonymous caller' },
},
];
const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
function discoverRouteModules(): string[] {
const apiDir = path.join(REPO_ROOT, 'app', 'api');
const found: string[] = [];
const walk = (dir: string): void => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolute);
} else if (entry.name === 'route.ts') {
found.push(path.relative(apiDir, absolute));
}
}
};
walk(apiDir);
return found.sort();
}
describe('auth matrix', () => {
const discovered = discoverRouteModules();
it('classifies every route module that exists on disk', () => {
const classified = new Set<string>([
...ROUTE_CASES.map((entry) => entry.file),
...PUBLIC_ROUTES.keys(),
]);
const unclassified = discovered.filter((file) => !classified.has(file));
const stale = [...classified].filter((file) => !discovered.includes(file)).sort();
// The failure message is the whole value of this assertion: whoever added
// the route needs to know what to do about it.
expect(
{ unclassified, stale },
'A route module under app/api is missing from tests/api/auth-matrix.test.ts. ' +
'Add it to ROUTE_CASES (the normal case: it requires a session), or to ' +
'PUBLIC_ROUTES with a comment saying why anonymous access is intended.'
).toEqual({ unclassified: [], stale: [] });
});
it('still has exactly the expected number of route modules', () => {
expect(discovered).toHaveLength(EXPECTED_ROUTE_MODULE_COUNT);
expect(ROUTE_CASES.length + PUBLIC_ROUTES.size).toBe(EXPECTED_ROUTE_MODULE_COUNT);
});
it('exports at least one HTTP method from every guarded route module', () => {
const withoutHandlers = ROUTE_CASES.filter(
(entry) => !HTTP_METHODS.some((method) => typeof entry.module[method] === 'function')
).map((entry) => entry.file);
expect(withoutHandlers).toEqual([]);
});
describe('unauthenticated callers', () => {
let fixtures: Fixtures;
beforeEach(async () => {
signedOut();
fixtures = await seedFixtures();
});
for (const entry of ROUTE_CASES) {
it(`never returns 2xx for ${entry.file}`, async () => {
const methods = HTTP_METHODS.filter((method) => typeof entry.module[method] === 'function');
expect(methods.length).toBeGreaterThan(0);
const observed: Record<string, number> = {};
for (const method of methods) {
const handler = entry.module[method] as RouteHandler<ParamRecord>;
const sendsBody = method !== 'GET' && method !== 'HEAD';
const request = apiRequest(entry.url(fixtures), {
method,
headers: entry.headers,
...(sendsBody
? entry.rawBody
? { rawBody: entry.rawBody(fixtures) }
: { body: entry.body ?? {} }
: {}),
});
const response = await callRoute(handler, request, entry.params?.(fixtures) ?? {});
observed[method] = response.status;
}
for (const [method, status] of Object.entries(observed)) {
expect(
status >= 200 && status < 300,
`${method} ${entry.file} returned ${status} to an anonymous caller`
).toBe(false);
// A crash is not a rejection. If this trips, the route threw on the
// way to its access check instead of refusing cleanly.
expect(status, `${method} ${entry.file} crashed instead of refusing`).not.toBe(500);
}
});
}
});
it('documents a reason for every public route, and each one still exists', () => {
for (const [file, reason] of PUBLIC_ROUTES) {
expect(reason.length, `${file} needs a reason`).toBeGreaterThan(10);
expect(fs.existsSync(path.join(REPO_ROOT, 'app', 'api', file))).toBe(true);
}
});
// -------------------------------------------------------------------------
// Signed in, but not an 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', () => {
let fixtures: Fixtures;
beforeEach(async () => {
fixtures = await seedFixtures();
});
it('refuses DELETE /api/admin/feedback/[feedbackId] and keeps the row', async () => {
signedInAs({ id: fixtures.userId, isAdmin: false });
const response = await callRoute(
adminFeedbackRoute.DELETE as unknown as RouteHandler<ParamRecord>,
apiRequest(`/api/admin/feedback/${fixtures.feedbackId}`, { method: 'DELETE' }),
{ feedbackId: fixtures.feedbackId }
);
expect(response.status).toBe(403);
expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(1);
});
it('refuses POST /api/admin/stats/refresh-r2', async () => {
signedInAs({ id: fixtures.userId, isAdmin: false });
const response = await callRoute(
adminRefreshR2Route.POST as RouteHandler<ParamRecord>,
apiRequest('/api/admin/stats/refresh-r2', { method: 'POST', body: {} })
);
expect(response.status).toBe(403);
});
});
});
File diff suppressed because it is too large Load Diff
+482
View File
@@ -0,0 +1,482 @@
// Authorization tests for the two download routes, from callers who are signed
// in but not entitled.
//
// Both routes are listed in tests/api/auth-matrix.test.ts, which proves only that
// an anonymous caller is refused. Downloads are the one place in this product
// where the read gate and the export gate are deliberately different: a
// COMMENTATOR may watch every frame of a project and still not be allowed to walk
// away with the files, and that distinction lives entirely in
// `canDownloadProjectMedia()`. Deleting it, or widening it from `access.canEdit ||
// allowDownloads` to `access.hasAccess`, does not move a single assertion in the
// anonymous matrix.
//
// The pairs below are built so the difference is visible: the same caller, the
// same URL, the same seeded rows, and only `project.allowDownloads` flipped
// between the refusal and the success.
//
// On /api/versions/[versionId]/download every fixture is an `r2` version on
// purpose. The route checks access first and only then rejects non-Bunny
// providers, so an authorized caller lands on a 400 that no unauthorized caller
// can reach. That 400 is the proof that the 403 came from the access check rather
// than from a malformed request: the two are different numbers on the same input.
// A `bunny` fixture would instead send the handler out to the Bunny CDN over the
// network, which has no place in this suite.
import { describe, expect, it } from 'vitest';
import type { Project, User, Video, VideoVersion, Workspace } from '@prisma/client';
import { db } from '@/lib/db';
import { GET as downloadProject } from '@/app/api/projects/[projectId]/download/route';
import { GET as downloadVersion } from '@/app/api/versions/[versionId]/download/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createUser,
createVersion,
createVideo,
nextSeq,
seedProject,
} from '../factories';
interface DownloadFixture {
owner: User;
workspace: Workspace;
project: Project;
video: Video;
version: VideoVersion;
/** The proxy path the manifest is expected to hand back for `version`. */
videoPath: string;
}
/**
* A project holding one video with one `r2` version.
*
* The provider matters twice over. `versionDownloadUrl()` only emits a manifest
* entry for a `bunny`, `r2` or `direct` version, so the default youtube fixture
* would produce an empty manifest and a 400 for every caller, authorized or not,
* which is exactly the "the route never reached the guard" trap. And
* `video_versions` carries a partial unique index on the provider video id, so
* two fixtures in one test need two distinct file names.
*/
async function seedDownloadable(
input: { allowDownloads: boolean; visibility?: 'PRIVATE' | 'PUBLIC'; ownerUser?: User } = {
allowDownloads: false,
}
): Promise<DownloadFixture> {
const { owner, workspace, project } = await seedProject({
ownerUser: input.ownerUser,
visibility: input.visibility ?? 'PRIVATE',
allowDownloads: input.allowDownloads,
});
const fileName = `44444444-4444-4444-8444-${String(nextSeq()).padStart(12, '0')}.mp4`;
const videoPath = `/api/upload/video/${fileName}`;
const video = await createVideo({ projectId: project.id, title: 'Downloadable video' });
const version = await createVersion({
videoParentId: video.id,
versionNumber: 1,
providerId: 'r2',
providerVideoId: `videos/${fileName}`,
originalUrl: videoPath,
sizeBytes: BigInt(2048),
isActive: true,
});
return { owner, workspace, project, video, version, videoPath };
}
function projectDownloadUrl(projectId: string): string {
return `/api/projects/${projectId}/download`;
}
function versionDownloadUrl(versionId: string): string {
return `/api/versions/${versionId}/download`;
}
interface Manifest {
projectName: string;
files: Array<{ fileName: string; url: string }>;
totalFiles: number;
}
// ---------------------------------------------------------------------------
// GET /api/projects/[projectId]/download
// ---------------------------------------------------------------------------
describe('GET /api/projects/[projectId]/download', () => {
it('returns 403 to a signed-in stranger even when downloads are allowed', async () => {
const fixture = await seedDownloadable({ allowDownloads: true });
// The stranger owns a real tenant of their own, so nothing about this call is
// malformed: they simply have no relationship to the target project.
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(403);
});
it('returns 403 to a project COMMENTATOR when downloads are disabled', async () => {
const fixture = await seedDownloadable({ allowDownloads: false });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(403);
});
// The positive control for the case above. Same role, same route, same seeded
// rows; only allowDownloads changed. If this one did not pass, the 403 above
// would prove nothing about the flag.
it('lets the same project COMMENTATOR download once allowDownloads is on', async () => {
const fixture = await seedDownloadable({ allowDownloads: true });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(200);
const manifest = await readData<Manifest>(response);
expect(manifest.totalFiles).toBe(1);
expect(manifest.files[0]?.url).toBe(fixture.videoPath);
});
it('returns 403 to a workspace COMMENTATOR when downloads are disabled', async () => {
const fixture = await seedDownloadable({ allowDownloads: false });
const workspaceCommentator = await createUser();
await addWorkspaceMember({
workspaceId: fixture.workspace.id,
userId: workspaceCommentator.id,
role: 'COMMENTATOR',
});
signedInAs(workspaceCommentator);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(403);
});
// A PUBLIC project hands `hasAccess` to anybody. It must not also hand out the
// source files: that is what the separate allowDownloads flag is for.
it('returns 403 to a signed-in passer-by on a PUBLIC project with downloads off', async () => {
const fixture = await seedDownloadable({ allowDownloads: false, visibility: 'PUBLIC' });
const passerBy = await createUser();
signedInAs(passerBy);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(403);
});
it('lets a signed-in passer-by download a PUBLIC project with downloads on', async () => {
const fixture = await seedDownloadable({ allowDownloads: true, visibility: 'PUBLIC' });
const passerBy = await createUser();
signedInAs(passerBy);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(200);
expect((await readData<Manifest>(response)).totalFiles).toBe(1);
});
// `canDownloadProjectMedia` starts from `access.hasAccess`, which is itself
// gated on the workspace owner's billing. A lapsed trial therefore closes the
// export path for the owner too, not only for the collaborators.
it('returns 403 to the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedDownloadable({ allowDownloads: true, ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(403);
});
// The IDOR shape for this route: the caller is legitimately entitled to the
// project in the path, and smuggles a foreign id through the `videoIds` filter.
// The video query is scoped by projectId, so the foreign id resolves to nothing
// and the route refuses the whole selection rather than silently dropping it.
it('returns 400 for a videoIds selection naming a video from another workspace', async () => {
const mine = await seedDownloadable({ allowDownloads: true });
const theirs = await seedDownloadable({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(mine.project.id), {
searchParams: { videoIds: theirs.video.id },
}),
{ projectId: mine.project.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('do not belong to this project');
});
// And the mixed selection, which is the version an attacker would actually
// send: one id they own, one they do not. Partial success would be the bug.
it('returns 400 for a videoIds selection mixing my video with a foreign one', async () => {
const mine = await seedDownloadable({ allowDownloads: true });
const theirs = await seedDownloadable({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(mine.project.id), {
searchParams: { videoIds: `${mine.video.id},${theirs.video.id}` },
}),
{ projectId: mine.project.id }
);
expect(response.status).toBe(400);
});
// Positive control for the two cases above: the identical request shape with
// only ids the caller owns succeeds, so the 400s are the scoping check and not
// a rejected query string.
it('lets the owner download an explicit selection of their own videos', async () => {
const mine = await seedDownloadable({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(mine.project.id), {
searchParams: { videoIds: mine.video.id },
}),
{ projectId: mine.project.id }
);
expect(response.status).toBe(200);
const manifest = await readData<Manifest>(response);
expect(manifest.totalFiles).toBe(1);
expect(manifest.files[0]?.url).toBe(mine.videoPath);
});
it('lets the owner download even when allowDownloads is off', async () => {
const fixture = await seedDownloadable({ allowDownloads: false });
signedInAs(fixture.owner);
const response = await callRoute(
downloadProject,
apiRequest(projectDownloadUrl(fixture.project.id)),
{
projectId: fixture.project.id,
}
);
expect(response.status).toBe(200);
expect((await readData<Manifest>(response)).totalFiles).toBe(1);
});
});
// ---------------------------------------------------------------------------
// GET /api/versions/[versionId]/download
// ---------------------------------------------------------------------------
// This route takes a bare versionId with no project in the path, so the only
// thing standing between a signed-in caller and any file in the database is the
// access check on the version's project. Every refusal below also asserts that no
// DownloadEgressEvent row was written, because that row is the billing record: a
// refusal that still bills the workspace owner would be its own bug.
describe('GET /api/versions/[versionId]/download', () => {
it('returns 403 to a signed-in stranger and records no egress', async () => {
const fixture = await seedDownloadable({ allowDownloads: true });
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(fixture.version.id)),
{ versionId: fixture.version.id }
);
expect(response.status).toBe(403);
expect(await db.downloadEgressEvent.count()).toBe(0);
});
it('returns 403 to a project COMMENTATOR when downloads are disabled', async () => {
const fixture = await seedDownloadable({ allowDownloads: false });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(fixture.version.id)),
{ versionId: fixture.version.id }
);
expect(response.status).toBe(403);
expect(await db.downloadEgressEvent.count()).toBe(0);
});
// The positive control. Same COMMENTATOR, same version, allowDownloads flipped
// on: the access check now passes and the request dies further down the handler
// on the provider check instead. 400 rather than 403 is what proves the 403
// above was the guard.
it('gets the same COMMENTATOR past the access check once allowDownloads is on', async () => {
const fixture = await seedDownloadable({ allowDownloads: true });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(fixture.version.id)),
{ versionId: fixture.version.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Bunny');
});
it('returns 403 to a workspace COMMENTATOR when downloads are disabled', async () => {
const fixture = await seedDownloadable({ allowDownloads: false });
const workspaceCommentator = await createUser();
await addWorkspaceMember({
workspaceId: fixture.workspace.id,
userId: workspaceCommentator.id,
role: 'COMMENTATOR',
});
signedInAs(workspaceCommentator);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(fixture.version.id)),
{ versionId: fixture.version.id }
);
expect(response.status).toBe(403);
expect(await db.downloadEgressEvent.count()).toBe(0);
});
it('returns 403 to the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedDownloadable({ allowDownloads: true, ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(fixture.version.id)),
{ versionId: fixture.version.id }
);
expect(response.status).toBe(403);
expect(await db.downloadEgressEvent.count()).toBe(0);
});
// Straight identifier substitution. There is no projectId in this URL to
// cross-check against, so the version id alone decides which project gets
// authorized. A caller who owns a perfectly good project of their own gets 403
// for somebody else's version, and never learns whether it exists.
it('returns 403 for a version id belonging to another workspace', async () => {
const mine = await seedDownloadable({ allowDownloads: true });
const theirs = await seedDownloadable({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(theirs.version.id)),
{ versionId: theirs.version.id }
);
expect(response.status).toBe(403);
expect(await db.downloadEgressEvent.count()).toBe(0);
});
// Positive control for the substitution case: the very same caller asking for
// their own version reaches the provider check. Only the id changed.
it('gets the same caller past the access check on their own version', async () => {
const mine = await seedDownloadable({ allowDownloads: true });
await seedDownloadable({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(mine.version.id)),
{ versionId: mine.version.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Bunny');
});
// `canDownloadProjectMedia` short-circuits on canEdit, so the owner is past the
// gate with allowDownloads off. Pinned because it is the one asymmetry that
// makes the COMMENTATOR cases above meaningful.
it('gets the owner past the access check even when allowDownloads is off', async () => {
const fixture = await seedDownloadable({ allowDownloads: false });
signedInAs(fixture.owner);
const response = await callRoute(
downloadVersion,
apiRequest(versionDownloadUrl(fixture.version.id)),
{ versionId: fixture.version.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Bunny');
});
});
+116
View File
@@ -0,0 +1,116 @@
// Guards the test harness itself. Every other file in tests/api depends on
// these four things being true, and when one of them silently stops being true
// the failures elsewhere look like product bugs.
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { cleanupRateLimits } from '@/lib/rate-limit';
import { GET as getProjects } from '@/app/api/projects/route';
import { countRows, listResettableTables, resetDb } from '../helpers/db';
import { apiRequest, callRoute } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
createComment,
createCommentTag,
createShareLink,
createUser,
seedVersion,
} from '../factories';
describe('api test infrastructure', () => {
it('points at the 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');
});
it('discovers every table from information_schema, so resetDb cannot drift', async () => {
const tables = await listResettableTables();
// Sampled across the schema rather than asserted exhaustively: a new model
// should not have to be added here, that is the whole point of reading
// information_schema.
expect(tables).toContain('users');
expect(tables).toContain('projects');
expect(tables).toContain('comments');
expect(tables).toContain('rate_limits');
expect(tables).toContain('video_upload_sessions');
expect(tables).not.toContain('_prisma_migrations');
});
it('resetDb empties tables that hold rows', async () => {
await createUser();
await createUser();
expect(await countRows('users')).toBe(2);
await resetDb();
expect(await countRows('users')).toBe(0);
});
// resetDb empties every table in one statement rather than in dependency
// order, relying on foreign-key triggers firing after the whole statement.
// This is the test that would catch that going wrong: the graph below spans
// parents and children in both alphabetical directions.
it('resetDb clears a full foreign-key graph in any direction', async () => {
const scenario = await seedVersion();
const tag = await createCommentTag({ projectId: scenario.project.id });
await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
tagId: tag.id,
});
await createShareLink({ projectId: scenario.project.id, videoId: scenario.video.id });
await db.$executeRaw`
INSERT INTO rate_limits (key, action, count, window_start)
VALUES ('reset-test', 'api', 1, NOW())
`;
await resetDb();
for (const table of await listResettableTables()) {
expect(await countRows(table), `${table} should be empty`).toBe(0);
}
});
it('resetDb restarts the rate_limits sequence, standing in for RESTART IDENTITY', async () => {
await db.$executeRaw`
INSERT INTO rate_limits (key, action, count, window_start)
VALUES ('seq-test-a', 'api', 1, NOW()), ('seq-test-b', 'api', 1, NOW())
`;
expect((await db.rateLimit.findFirstOrThrow({ orderBy: { id: 'desc' } })).id).toBe(2);
await resetDb();
await db.$executeRaw`
INSERT INTO rate_limits (key, action, count, window_start)
VALUES ('seq-test-c', 'api', 1, NOW())
`;
expect((await db.rateLimit.findFirstOrThrow()).id).toBe(1);
});
it('exposes cleanup_rate_limits(), which prisma db push does not create', async () => {
await db.$executeRaw`
INSERT INTO rate_limits (key, action, count, window_start)
VALUES ('infra-test', 'api', 1, NOW() - INTERVAL '2 hours')
`;
expect(await countRows('rate_limits')).toBe(1);
await cleanupRateLimits();
expect(await countRows('rate_limits')).toBe(0);
});
it('mocks auth() while leaving the rest of @/lib/auth real', async () => {
signedOut();
const anonymous = await callRoute(getProjects, apiRequest('/api/projects'));
expect(anonymous.status).toBe(401);
const user = await createUser();
signedInAs(user);
const authenticated = await callRoute(getProjects, apiRequest('/api/projects'));
expect(authenticated.status).toBe(200);
});
});
+753
View File
@@ -0,0 +1,753 @@
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import {
GET as listProjectMembers,
POST as inviteProjectMember,
} from '@/app/api/projects/[projectId]/members/route';
import {
DELETE as removeProjectMember,
PATCH as patchProjectMember,
} from '@/app/api/projects/[projectId]/members/[memberId]/route';
import { DELETE as cancelProjectInvitation } from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route';
import { PATCH as patchWorkspaceMember } from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { mailTo, sentMail } from '../helpers/mail';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createInvitation,
createUser,
seedProject,
} from '../factories';
interface MembersPayload {
members: Array<{ id: string; role: string; user: { id: string } }>;
owner: { id: string } | null;
pendingInvitations: Array<{ id: string; email: string; role: string }>;
}
function membersUrl(projectId: string): string {
return `/api/projects/${projectId}/members`;
}
describe('GET /api/projects/[projectId]/members', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
listProjectMembers,
apiRequest(membersUrl(scenario.project.id)),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
});
it('returns 403 for a signed-in stranger', async () => {
const scenario = await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
listProjectMembers,
apiRequest(membersUrl(scenario.project.id)),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
// The project is PUBLIC, so checkProjectAccess grants hasAccess to anyone.
// The route adds `(!isOwner && !isMember)` on top, which is what keeps the
// member roster out of a passer-by's hands.
it('returns 403 to a non-member even on a PUBLIC project', async () => {
const scenario = await seedProject({ visibility: 'PUBLIC' });
const passerBy = await createUser();
signedInAs(passerBy);
const response = await callRoute(
listProjectMembers,
apiRequest(membersUrl(scenario.project.id)),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('hides pending invitations from a COMMENTATOR and shows them to an ADMIN', async () => {
const scenario = await seedProject();
const commentator = await createUser();
const admin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
});
signedInAs(commentator);
const asCommentator = await readData<MembersPayload>(
await callRoute(listProjectMembers, apiRequest(membersUrl(scenario.project.id)), {
projectId: scenario.project.id,
})
);
signedInAs(admin);
const asAdmin = await readData<MembersPayload>(
await callRoute(listProjectMembers, apiRequest(membersUrl(scenario.project.id)), {
projectId: scenario.project.id,
})
);
expect(asCommentator.pendingInvitations).toEqual([]);
expect(asAdmin.pendingInvitations.map((entry) => entry.id)).toEqual([invitation.id]);
expect(asCommentator.members).toHaveLength(2);
expect(asCommentator.owner?.id).toBe(scenario.owner.id);
});
it('omits an expired pending invitation', async () => {
const scenario = await seedProject();
await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
expiresAt: new Date(Date.now() - 60_000),
});
signedInAs(scenario.owner);
const payload = await readData<MembersPayload>(
await callRoute(listProjectMembers, apiRequest(membersUrl(scenario.project.id)), {
projectId: scenario.project.id,
})
);
expect(payload.pendingInvitations).toEqual([]);
});
});
describe('POST /api/projects/[projectId]/members', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), { body: { email: '[email protected]' } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect(await db.invitation.count()).toBe(0);
});
// A COMMENTATOR is the "viewer" of this product. It must not be able to grow
// the project's membership.
it('returns 403 for a project COMMENTATOR and writes no invitation', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), {
body: { email: '[email protected]', role: 'ADMIN' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.invitation.count()).toBe(0);
expect(sentMail()).toEqual([]);
});
it('returns 403 for a stranger', async () => {
const scenario = await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), { body: { email: '[email protected]' } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.invitation.count()).toBe(0);
});
// Documents current behaviour. A workspace ADMIN has canEdit on the project
// (so it can rename it) but the route additionally demands project ownership
// or project ADMIN membership, so it cannot invite. Flagged in the report as
// an inconsistency rather than a security hole.
it('returns 403 for a workspace ADMIN who is not a project member', async () => {
const scenario = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), { body: { email: '[email protected]' } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 400 when the email is missing', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), { body: { role: 'ADMIN' } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
});
it.each([['not-an-email'], ['no@domain'], ['two@@example.com'], ['@example.com']])(
'returns 422 for the malformed address %s',
async (email) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), { body: { email } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(422);
expect(await db.invitation.count()).toBe(0);
}
);
it('returns 400 when invited address is the project owner', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), { body: { email: scenario.owner.email! } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
expect(await db.invitation.count()).toBe(0);
});
it('returns 409 when the address already belongs to a member', async () => {
const scenario = await seedProject();
const existing = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: existing.id });
signedInAs(scenario.owner);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), { body: { email: existing.email! } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(409);
expect(await db.invitation.count()).toBe(0);
});
it('creates a PROJECT-scoped invitation and sends the mail', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), {
body: { email: ' [email protected] ', role: 'ADMIN' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const invitation = await db.invitation.findFirstOrThrow();
expect(invitation.email).toBe('[email protected]');
expect(invitation.scope).toBe('PROJECT');
expect(invitation.role).toBe('ADMIN');
expect(invitation.status).toBe('PENDING');
expect(invitation.projectId).toBe(scenario.project.id);
expect(invitation.workspaceId).toBeNull();
expect(invitation.invitedById).toBe(scenario.owner.id);
expect(invitation.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(invitation.token.length).toBeGreaterThanOrEqual(32);
// No membership yet: the invitation has to be accepted first.
expect(await db.projectMember.count()).toBe(0);
const mails = mailTo('[email protected]');
expect(mails).toHaveLength(1);
expect(mails[0].html).toContain(invitation.token);
});
it('falls back to COMMENTATOR for an unrecognised role', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), {
body: { email: '[email protected]', role: 'SUPERADMIN' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
expect((await db.invitation.findFirstOrThrow()).role).toBe('COMMENTATOR');
});
it('lets a project ADMIN invite, and reuses the row on a repeat invite', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const first = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), {
body: { email: '[email protected]', role: 'COMMENTATOR' },
}),
{ projectId: scenario.project.id }
);
const firstToken = (await db.invitation.findFirstOrThrow()).token;
const second = await callRoute(
inviteProjectMember,
apiRequest(membersUrl(scenario.project.id), {
body: { email: '[email protected]', role: 'ADMIN' },
}),
{ projectId: scenario.project.id }
);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(await db.invitation.count()).toBe(1);
const invitation = await db.invitation.findFirstOrThrow();
expect(invitation.role).toBe('ADMIN');
expect(invitation.token).not.toBe(firstToken);
});
});
describe('PATCH /api/projects/[projectId]/members/[memberId]', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
const target = await createUser();
const member = await addProjectMember({
projectId: scenario.project.id,
userId: target.id,
});
signedOut();
const response = await callRoute(
patchProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${member.id}`, {
method: 'PATCH',
body: { role: 'ADMIN' },
}),
{ projectId: scenario.project.id, memberId: member.id }
);
expect(response.status).toBe(401);
expect((await db.projectMember.findUniqueOrThrow({ where: { id: member.id } })).role).toBe(
'COMMENTATOR'
);
});
// The escalation that matters: a COMMENTATOR promoting itself to ADMIN.
it('returns 403 when a COMMENTATOR tries to promote itself', async () => {
const scenario = await seedProject();
const commentator = await createUser();
const member = await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
patchProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${member.id}`, {
method: 'PATCH',
body: { role: 'ADMIN' },
}),
{ projectId: scenario.project.id, memberId: member.id }
);
expect(response.status).toBe(403);
expect((await db.projectMember.findUniqueOrThrow({ where: { id: member.id } })).role).toBe(
'COMMENTATOR'
);
});
it.each([['OWNER'], ['SUPERADMIN'], ['']])('returns 400 for the role %s', async (role) => {
const scenario = await seedProject();
const target = await createUser();
const member = await addProjectMember({
projectId: scenario.project.id,
userId: target.id,
});
signedInAs(scenario.owner);
const response = await callRoute(
patchProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${member.id}`, {
method: 'PATCH',
body: { role },
}),
{ projectId: scenario.project.id, memberId: member.id }
);
expect(response.status).toBe(400);
expect((await db.projectMember.findUniqueOrThrow({ where: { id: member.id } })).role).toBe(
'COMMENTATOR'
);
});
// The membership row id is a global identifier, so the route has to scope the
// lookup by projectId or an admin of one project could re-role a member of
// another.
it('returns 404 for a membership row that belongs to a different project', async () => {
const mine = await seedProject();
const theirs = await seedProject();
const victim = await createUser();
const foreignMember = await addProjectMember({
projectId: theirs.project.id,
userId: victim.id,
role: 'COMMENTATOR',
});
signedInAs(mine.owner);
const response = await callRoute(
patchProjectMember,
apiRequest(`${membersUrl(mine.project.id)}/${foreignMember.id}`, {
method: 'PATCH',
body: { role: 'ADMIN' },
}),
{ projectId: mine.project.id, memberId: foreignMember.id }
);
expect(response.status).toBe(404);
expect(
(await db.projectMember.findUniqueOrThrow({ where: { id: foreignMember.id } })).role
).toBe('COMMENTATOR');
});
it('lets the owner promote a COMMENTATOR to ADMIN', async () => {
const scenario = await seedProject();
const target = await createUser();
const member = await addProjectMember({
projectId: scenario.project.id,
userId: target.id,
role: 'COMMENTATOR',
});
signedInAs(scenario.owner);
const response = await callRoute(
patchProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${member.id}`, {
method: 'PATCH',
body: { role: 'ADMIN' },
}),
{ projectId: scenario.project.id, memberId: member.id }
);
expect(response.status).toBe(200);
expect((await db.projectMember.findUniqueOrThrow({ where: { id: member.id } })).role).toBe(
'ADMIN'
);
});
});
describe('DELETE /api/projects/[projectId]/members/[memberId]', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
const target = await createUser();
const member = await addProjectMember({
projectId: scenario.project.id,
userId: target.id,
});
signedOut();
const response = await callRoute(
removeProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${member.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id, memberId: member.id }
);
expect(response.status).toBe(401);
expect(await db.projectMember.count()).toBe(1);
});
it('returns 403 when a COMMENTATOR tries to remove somebody else', async () => {
const scenario = await seedProject();
const commentator = await createUser();
const victim = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
const victimMember = await addProjectMember({
projectId: scenario.project.id,
userId: victim.id,
});
signedInAs(commentator);
const response = await callRoute(
removeProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${victimMember.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id, memberId: victimMember.id }
);
expect(response.status).toBe(403);
expect(await db.projectMember.count()).toBe(2);
});
it('lets a COMMENTATOR remove itself', async () => {
const scenario = await seedProject();
const commentator = await createUser();
const member = await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
removeProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${member.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id, memberId: member.id }
);
expect(response.status).toBe(200);
expect(await db.projectMember.count()).toBe(0);
});
// The owner is not a ProjectMember row at all, so there is nothing to delete
// and no path that can orphan a project. Pinning it here so a future refactor
// that starts materialising the owner as a member has to think about it.
it('cannot remove the project owner, who has no membership row', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const byUserId = await callRoute(
removeProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${scenario.owner.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id, memberId: scenario.owner.id }
);
expect(byUserId.status).toBe(404);
expect(
await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })
).toMatchObject({ ownerId: scenario.owner.id });
});
it('lets a project ADMIN remove a COMMENTATOR', async () => {
const scenario = await seedProject();
const admin = await createUser();
const victim = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
const victimMember = await addProjectMember({
projectId: scenario.project.id,
userId: victim.id,
});
signedInAs(admin);
const response = await callRoute(
removeProjectMember,
apiRequest(`${membersUrl(scenario.project.id)}/${victimMember.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id, memberId: victimMember.id }
);
expect(response.status).toBe(200);
expect(await db.projectMember.findUnique({ where: { id: victimMember.id } })).toBeNull();
});
});
describe('project ADMIN scope', () => {
// The escalation to actually worry about: project ADMIN is a role inside one
// project, and it must not reach the workspace roster, which controls every
// project in the workspace.
it('cannot change a workspace member role', async () => {
const scenario = await seedProject();
const projectAdmin = await createUser();
const workspaceVictim = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: projectAdmin.id,
role: 'ADMIN',
});
const workspaceMember = await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceVictim.id,
role: 'COMMENTATOR',
});
signedInAs(projectAdmin);
const response = await callRoute(
patchWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/${workspaceMember.id}`, {
method: 'PATCH',
body: { role: 'ADMIN' },
}),
{ workspaceId: scenario.workspace.id, memberId: workspaceMember.id }
);
expect(response.status).toBe(403);
expect(
(await db.workspaceMember.findUniqueOrThrow({ where: { id: workspaceMember.id } })).role
).toBe('COMMENTATOR');
});
it('cannot invite into the workspace', async () => {
const scenario = await seedProject();
const projectAdmin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: projectAdmin.id,
role: 'ADMIN',
});
signedInAs(projectAdmin);
const { POST: inviteWorkspaceMember } =
await import('@/app/api/workspaces/[workspaceId]/members/route');
const response = await callRoute(
inviteWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members`, {
body: { email: '[email protected]', role: 'ADMIN' },
}),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(403);
expect(await db.invitation.count()).toBe(0);
});
});
describe('DELETE /api/projects/[projectId]/members/invitations/[invitationId]', () => {
it('returns 403 for a COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
});
signedInAs(commentator);
const response = await callRoute(
cancelProjectInvitation,
apiRequest(`${membersUrl(scenario.project.id)}/invitations/${invitation.id}`, {
method: 'DELETE',
}),
{ projectId: scenario.project.id, invitationId: invitation.id }
);
expect(response.status).toBe(403);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
it('returns 404 for an invitation scoped to another project', async () => {
const mine = await seedProject();
const theirs = await seedProject();
const invitation = await createInvitation({
invitedById: theirs.owner.id,
scope: 'PROJECT',
projectId: theirs.project.id,
});
signedInAs(mine.owner);
const response = await callRoute(
cancelProjectInvitation,
apiRequest(`${membersUrl(mine.project.id)}/invitations/${invitation.id}`, {
method: 'DELETE',
}),
{ projectId: mine.project.id, invitationId: invitation.id }
);
expect(response.status).toBe(404);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
it('returns 409 for an already accepted invitation', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
status: 'ACCEPTED',
acceptedAt: new Date(),
});
signedInAs(scenario.owner);
const response = await callRoute(
cancelProjectInvitation,
apiRequest(`${membersUrl(scenario.project.id)}/invitations/${invitation.id}`, {
method: 'DELETE',
}),
{ projectId: scenario.project.id, invitationId: invitation.id }
);
expect(response.status).toBe(409);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'ACCEPTED'
);
});
it('marks a pending invitation CANCELED for the owner', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
});
signedInAs(scenario.owner);
const response = await callRoute(
cancelProjectInvitation,
apiRequest(`${membersUrl(scenario.project.id)}/invitations/${invitation.id}`, {
method: 'DELETE',
}),
{ projectId: scenario.project.id, invitationId: invitation.id }
);
expect(response.status).toBe(200);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'CANCELED'
);
});
});
+531
View File
@@ -0,0 +1,531 @@
// Authorization tests for the remaining project-scoped routes that the suite
// only ever exercised anonymously: the comment tag CRUD, the project-level Bunny
// upload initializer, the comment export, and the workspace invitation cancel.
//
// None of these is the flashiest endpoint in the app, and that is exactly why
// they are worth pinning. Each one is a small handler whose entire access story
// is a single `if (!access.canEdit)` that no test had ever exercised with a
// signed-in caller. A tag route that any project member can rewrite is a way to
// vandalise every review in the project; a `bunny-init` that any member can call
// is a way to spend the workspace owner's storage; and the invitation cancel is
// the workspace roster.
//
// The pattern is the same throughout: a signed-in stranger who owns a real
// workspace of their own, a COMMENTATOR who is a genuine member, a
// cross-tenant identifier substitution where the route takes two ids, and a
// positive control that reaches a status the refusals cannot produce.
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { GET as listTags, POST as createTag } from '@/app/api/projects/[projectId]/tags/route';
import {
DELETE as deleteTag,
PATCH as patchTag,
} from '@/app/api/projects/[projectId]/tags/[tagId]/route';
import { POST as initProjectBunnyUpload } from '@/app/api/projects/[projectId]/videos/bunny-init/route';
import { GET as exportComments } from '@/app/api/versions/[versionId]/comments/export/route';
import { DELETE as cancelWorkspaceInvitation } from '@/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createComment,
createCommentTag,
createInvitation,
createUser,
seedProject,
seedVersion,
} from '../factories';
const SEEDED_TAG_NAME = 'Seeded tag';
const SEEDED_TAG_COLOR = '#3B82F6';
function tagsUrl(projectId: string): string {
return `/api/projects/${projectId}/tags`;
}
// ---------------------------------------------------------------------------
// Comment tags
// ---------------------------------------------------------------------------
describe('POST /api/projects/[projectId]/tags', () => {
it('returns 403 to a signed-in stranger and writes no tag', async () => {
const scenario = await seedProject();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
createTag,
apiRequest(tagsUrl(scenario.project.id), {
body: { name: 'Stranger tag', color: '#ff0000' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.commentTag.count()).toBe(0);
});
it('returns 403 to a project COMMENTATOR and writes no tag', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createTag,
apiRequest(tagsUrl(scenario.project.id), {
body: { name: 'Commentator tag', color: '#ff0000' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.commentTag.count()).toBe(0);
});
it('returns 403 to a workspace COMMENTATOR and writes no tag', async () => {
const scenario = await seedProject();
const workspaceCommentator = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceCommentator.id,
role: 'COMMENTATOR',
});
signedInAs(workspaceCommentator);
const response = await callRoute(
createTag,
apiRequest(tagsUrl(scenario.project.id), {
body: { name: 'Workspace commentator tag', color: '#ff0000' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.commentTag.count()).toBe(0);
});
it('lets the project owner create a tag', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
createTag,
apiRequest(tagsUrl(scenario.project.id), { body: { name: 'Owner tag', color: '#ff0000' } }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(201);
const tag = await db.commentTag.findFirstOrThrow();
expect(tag.name).toBe('Owner tag');
expect(tag.projectId).toBe(scenario.project.id);
});
});
describe('PATCH and DELETE /api/projects/[projectId]/tags/[tagId]', () => {
it('returns 403 to a project COMMENTATOR and leaves the tag alone', async () => {
const scenario = await seedProject();
const tag = await createCommentTag({
projectId: scenario.project.id,
name: SEEDED_TAG_NAME,
color: SEEDED_TAG_COLOR,
});
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
patchTag,
apiRequest(`${tagsUrl(scenario.project.id)}/${tag.id}`, {
method: 'PATCH',
body: { name: 'Renamed by a commentator', color: '#000000' },
}),
{ projectId: scenario.project.id, tagId: tag.id }
);
expect(response.status).toBe(403);
const after = await db.commentTag.findUniqueOrThrow({ where: { id: tag.id } });
expect(after.name).toBe(SEEDED_TAG_NAME);
expect(after.color).toBe(SEEDED_TAG_COLOR);
});
it('returns 403 to a project COMMENTATOR deleting a tag and keeps the row', async () => {
const scenario = await seedProject();
const tag = await createCommentTag({ projectId: scenario.project.id, name: SEEDED_TAG_NAME });
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
deleteTag,
apiRequest(`${tagsUrl(scenario.project.id)}/${tag.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id, tagId: tag.id }
);
expect(response.status).toBe(403);
expect(await db.commentTag.count({ where: { id: tag.id } })).toBe(1);
});
// The tag id is a global identifier and the project id in the path is what
// scopes it. An admin of one project must not be able to rename another
// project's tags by pasting the id in.
it('returns 404 for a tag belonging to another project and leaves it alone', async () => {
const mine = await seedProject();
const theirs = await seedProject();
const foreignTag = await createCommentTag({
projectId: theirs.project.id,
name: SEEDED_TAG_NAME,
color: SEEDED_TAG_COLOR,
});
signedInAs(mine.owner);
const response = await callRoute(
patchTag,
apiRequest(`${tagsUrl(mine.project.id)}/${foreignTag.id}`, {
method: 'PATCH',
body: { name: 'Renamed across the tenant boundary' },
}),
{ projectId: mine.project.id, tagId: foreignTag.id }
);
expect(response.status).toBe(404);
const after = await db.commentTag.findUniqueOrThrow({ where: { id: foreignTag.id } });
expect(after.name).toBe(SEEDED_TAG_NAME);
expect(after.color).toBe(SEEDED_TAG_COLOR);
});
it('returns 404 when deleting a tag belonging to another project', async () => {
const mine = await seedProject();
const theirs = await seedProject();
const foreignTag = await createCommentTag({
projectId: theirs.project.id,
name: SEEDED_TAG_NAME,
});
signedInAs(mine.owner);
const response = await callRoute(
deleteTag,
apiRequest(`${tagsUrl(mine.project.id)}/${foreignTag.id}`, { method: 'DELETE' }),
{ projectId: mine.project.id, tagId: foreignTag.id }
);
expect(response.status).toBe(404);
expect(await db.commentTag.count({ where: { id: foreignTag.id } })).toBe(1);
});
it('lets the project owner rename and then delete a tag', async () => {
const scenario = await seedProject();
const tag = await createCommentTag({
projectId: scenario.project.id,
name: SEEDED_TAG_NAME,
color: SEEDED_TAG_COLOR,
});
signedInAs(scenario.owner);
const renamed = await callRoute(
patchTag,
apiRequest(`${tagsUrl(scenario.project.id)}/${tag.id}`, {
method: 'PATCH',
body: { name: 'Renamed by the owner' },
}),
{ projectId: scenario.project.id, tagId: tag.id }
);
expect(renamed.status).toBe(200);
expect((await db.commentTag.findUniqueOrThrow({ where: { id: tag.id } })).name).toBe(
'Renamed by the owner'
);
const removed = await callRoute(
deleteTag,
apiRequest(`${tagsUrl(scenario.project.id)}/${tag.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id, tagId: tag.id }
);
expect(removed.status).toBe(200);
expect(await db.commentTag.count({ where: { id: tag.id } })).toBe(0);
});
// GET is deliberately looser than the writes: a COMMENTATOR needs the tag list
// to file a comment against one. Pinned so the read gate and the write gate stay
// visibly different.
it('lets a project COMMENTATOR read the tag list', async () => {
const scenario = await seedProject();
const tag = await createCommentTag({ projectId: scenario.project.id, name: SEEDED_TAG_NAME });
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listTags, apiRequest(tagsUrl(scenario.project.id)), {
projectId: scenario.project.id,
});
expect(response.status).toBe(200);
const tags = await readData<Array<{ id: string }>>(response);
expect(tags.map((entry) => entry.id)).toEqual([tag.id]);
});
});
// ---------------------------------------------------------------------------
// POST /api/projects/[projectId]/videos/bunny-init
// ---------------------------------------------------------------------------
// Hands out a signed upload credential against the workspace owner's Bunny
// library, so the guard here is the thing standing between a COMMENTATOR and
// somebody else's storage bill.
describe('POST /api/projects/[projectId]/videos/bunny-init', () => {
it('returns 403 to a signed-in stranger', async () => {
const scenario = await seedProject();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
initProjectBunnyUpload,
apiRequest(`/api/projects/${scenario.project.id}/videos/bunny-init`, {
body: { title: 'A clip' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 403 to a project COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initProjectBunnyUpload,
apiRequest(`/api/projects/${scenario.project.id}/videos/bunny-init`, {
body: { title: 'A clip' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
// Positive control. Bunny uploads are unconfigured in the test environment, so
// an authorized caller stops on the feature check one line below the guard. A
// 400 is a status neither refusal above can reach.
it('gets the project owner past the access check onto the disabled-feature check', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
initProjectBunnyUpload,
apiRequest(`/api/projects/${scenario.project.id}/videos/bunny-init`, {
body: { title: 'A clip' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Bunny direct uploads are disabled');
});
});
// ---------------------------------------------------------------------------
// GET /api/versions/[versionId]/comments/export
// ---------------------------------------------------------------------------
// The export takes a bare versionId, so the version alone decides which project
// is authorized. It also masks a refusal as a 404 rather than a 403, which is a
// reasonable thing to do and a terrible thing to test without a positive control:
// a 404 is exactly what a wrong id would produce too.
describe('GET /api/versions/[versionId]/comments/export', () => {
it('returns 404 to a signed-in stranger', async () => {
const scenario = await seedVersion();
await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id });
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
exportComments,
apiRequest(`/api/versions/${scenario.version.id}/comments/export`),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(404);
});
it('returns 404 for a version id belonging to another workspace', async () => {
const mine = await seedVersion();
const theirs = await seedVersion();
await createComment({ versionId: theirs.version.id, authorId: theirs.owner.id });
signedInAs(mine.owner);
const response = await callRoute(
exportComments,
apiRequest(`/api/versions/${theirs.version.id}/comments/export`),
{ versionId: theirs.version.id }
);
expect(response.status).toBe(404);
});
// Positive control: the identical request from a caller who is only a
// COMMENTATOR succeeds, so the two 404s above are the access check and not a
// dead id.
it('lets a project COMMENTATOR export the comments as CSV', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
content: 'Exportable comment',
});
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
exportComments,
apiRequest(`/api/versions/${scenario.version.id}/comments/export`),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(200);
expect(await response.text()).toContain('Exportable comment');
});
});
// ---------------------------------------------------------------------------
// DELETE /api/workspaces/[workspaceId]/members/invitations/[invitationId]
// ---------------------------------------------------------------------------
describe('DELETE /api/workspaces/[workspaceId]/members/invitations/[invitationId]', () => {
it('returns 403 to a signed-in stranger and leaves the invitation pending', async () => {
const scenario = await seedProject();
await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
});
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
cancelWorkspaceInvitation,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/invitations/${invitation.id}`, {
method: 'DELETE',
}),
{ workspaceId: scenario.workspace.id, invitationId: invitation.id }
);
expect(response.status).toBe(403);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
it('returns 403 to a workspace COMMENTATOR and leaves the invitation pending', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
});
const workspaceCommentator = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceCommentator.id,
role: 'COMMENTATOR',
});
signedInAs(workspaceCommentator);
const response = await callRoute(
cancelWorkspaceInvitation,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/invitations/${invitation.id}`, {
method: 'DELETE',
}),
{ workspaceId: scenario.workspace.id, invitationId: invitation.id }
);
expect(response.status).toBe(403);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
it('returns 404 for an invitation scoped to another workspace', async () => {
const mine = await seedProject();
const theirs = await seedProject();
const foreignInvitation = await createInvitation({
invitedById: theirs.owner.id,
scope: 'WORKSPACE',
workspaceId: theirs.workspace.id,
});
signedInAs(mine.owner);
const response = await callRoute(
cancelWorkspaceInvitation,
apiRequest(
`/api/workspaces/${mine.workspace.id}/members/invitations/${foreignInvitation.id}`,
{ method: 'DELETE' }
),
{ workspaceId: mine.workspace.id, invitationId: foreignInvitation.id }
);
expect(response.status).toBe(404);
expect(
(await db.invitation.findUniqueOrThrow({ where: { id: foreignInvitation.id } })).status
).toBe('PENDING');
});
it('lets a workspace ADMIN cancel the invitation', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
});
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
cancelWorkspaceInvitation,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/invitations/${invitation.id}`, {
method: 'DELETE',
}),
{ workspaceId: scenario.workspace.id, invitationId: invitation.id }
);
expect(response.status).toBe(200);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'CANCELED'
);
});
});
+741
View File
@@ -0,0 +1,741 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { GET as listProjects, POST as createProjectRoute } from '@/app/api/projects/route';
import {
DELETE as deleteProject,
GET as getProject,
PATCH as patchProject,
} from '@/app/api/projects/[projectId]/route';
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createUser,
createVideo,
createWorkspace,
seedProject,
} from '../factories';
interface ListedProject {
id: string;
name: string;
}
async function listFor(userId: string, query = ''): Promise<Response> {
signedInAs({ id: userId });
return callRoute(listProjects, apiRequest(`/api/projects${query}`));
}
describe('GET /api/projects', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(listProjects, apiRequest('/api/projects'));
expect(response.status).toBe(401);
});
it.each([
['page=0', 'page 0 is below the minimum'],
['page=1001', 'page 1001 is past MAX_PAGE'],
['page=1.5', 'a non-integer page'],
['page=abc', 'an unparseable page'],
['limit=0', 'limit 0 is below the minimum'],
['limit=101', 'limit 101 is past MAX_LIMIT'],
['page=1000&limit=100', 'an offset of 99900 is past MAX_OFFSET'],
])('rejects ?%s with 400 (%s)', async (query, label) => {
const user = await createUser();
const response = await listFor(user.id, `?${query}`);
expect(response.status, label).toBe(400);
});
it('accepts the boundary values page=1000&limit=10 and limit=100', async () => {
const user = await createUser();
expect((await listFor(user.id, '?page=1000&limit=10')).status).toBe(200);
expect((await listFor(user.id, '?limit=100')).status).toBe(200);
});
it('lists projects the caller owns, with pagination metadata', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'Second' });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'Third' });
const response = await listFor(owner.id, '?page=2&limit=2');
const payload = await readJson<{
data: { projects: ListedProject[] };
meta: { page: number; limit: number; total: number; totalPages: number };
}>(response);
expect(response.status).toBe(200);
expect(payload.data.projects).toHaveLength(1);
expect(payload.meta).toEqual({ page: 2, limit: 2, total: 3, totalPages: 2 });
});
it('does not list projects belonging to another user', async () => {
const stranger = await seedProject();
const caller = await createUser();
const projects = await readData<{ projects: ListedProject[] }>(await listFor(caller.id));
expect(projects.projects).toEqual([]);
// Sanity check that the arrangement was real and the empty result is about
// the filter rather than about an empty database.
expect(await db.project.count()).toBe(1);
expect(stranger.project.id).toBeTruthy();
});
// This is the buildBillingAccessWhereInput() guard in the list filter. Both
// halves are here on purpose: without the positive control, deleting the
// clause entirely would still leave the negative test passing for the wrong
// reason.
it('hides a project whose workspace owner has lost billing access, even from its owner', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
const payload = await readJson<{
data: { projects: ListedProject[] };
meta: { total: number };
}>(await listFor(expiredOwner.id));
expect(payload.data.projects).toEqual([]);
expect(payload.meta.total).toBe(0);
});
it('lists the same project once the owner has billing access again', async () => {
const owner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
await db.user.update({
where: { id: owner.id },
data: { trialEndsAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
});
const projects = await readData<{ projects: ListedProject[] }>(await listFor(owner.id));
expect(projects.projects.map((entry) => entry.id)).toEqual([project.id]);
});
it('hides a project from a member when the workspace owner has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({
ownerId: expiredOwner.id,
workspaceId: workspace.id,
});
const member = await createUser();
await addProjectMember({ projectId: project.id, userId: member.id });
const projects = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
expect(projects.projects).toEqual([]);
});
it('lists a workspace member the projects of that workspace', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: member.id });
const projects = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
expect(projects.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
});
// Documents current behaviour, which looks like a bug. See the note in the
// report: the workspace-membership branch of the OR is dropped as soon as
// ?workspaceId is supplied, so filtering by workspace hides exactly the
// projects the unfiltered call returns.
it('stops listing workspace-member projects once ?workspaceId is supplied', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: member.id });
const unfiltered = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
const filtered = await readData<{ projects: ListedProject[] }>(
await listFor(member.id, `?workspaceId=${scenario.workspace.id}`)
);
expect(unfiltered.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
expect(filtered.projects).toEqual([]);
});
it('scopes ?workspaceId to that workspace for an owner of several', async () => {
const owner = await createUser();
const first = await createWorkspace({ ownerId: owner.id });
const second = await createWorkspace({ ownerId: owner.id });
const wanted = await createProject({ ownerId: owner.id, workspaceId: first.id });
await createProject({ ownerId: owner.id, workspaceId: second.id });
const projects = await readData<{ projects: ListedProject[] }>(
await listFor(owner.id, `?workspaceId=${first.id}`)
);
expect(projects.projects.map((entry) => entry.id)).toEqual([wanted.id]);
});
});
describe('POST /api/projects', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'X', workspaceId: 'y' } })
);
expect(response.status).toBe(401);
expect(await db.project.count()).toBe(0);
});
it.each([
[{ workspaceId: 'w' }, 'a missing name'],
[{ name: ' ', workspaceId: 'w' }, 'a blank name'],
[{ name: 42, workspaceId: 'w' }, 'a non-string name'],
[{ name: 'Valid' }, 'a missing workspaceId'],
[{ name: 'Valid', workspaceId: 17 }, 'a non-string workspaceId'],
])('rejects %j with 400 (%s)', async (body, label) => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(createProjectRoute, apiRequest('/api/projects', { body }));
expect(response.status, label).toBe(400);
expect(await db.project.count()).toBe(0);
});
it('returns 404 for a workspace that does not exist', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Orphan', workspaceId: 'no-such-workspace' } })
);
expect(response.status).toBe(404);
});
it('returns 403 for a workspace COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Sneaky', workspaceId: scenario.workspace.id },
})
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('returns 403 when the workspace owner has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
signedInAs(expiredOwner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Blocked', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(0);
});
it('creates the project with the five default comment tags', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'My Project', description: ' spaced ', workspaceId: workspace.id },
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow({ include: { commentTags: true } });
expect(stored.name).toBe('My Project');
expect(stored.description).toBe('spaced');
expect(stored.slug).toBe('my-project');
expect(stored.visibility).toBe('PRIVATE');
expect(stored.allowDownloads).toBe(false);
expect(stored.workspaceId).toBe(workspace.id);
expect(stored.commentTags.map((tag) => tag.name).sort()).toEqual(
DEFAULT_COMMENT_TAGS.map((tag) => tag.name).sort()
);
expect(stored.commentTags.map((tag) => tag.color).sort()).toEqual(
DEFAULT_COMMENT_TAGS.map((tag) => tag.color).sort()
);
});
// The row's owner is the workspace owner, never the caller: ownership drives
// billing, and a workspace admin creating a project must not shift the bill.
it('assigns the workspace owner as project owner when a workspace ADMIN creates it', async () => {
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const admin = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Admin Project', workspaceId: workspace.id },
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow();
expect(stored.ownerId).toBe(workspaceOwner.id);
expect(stored.ownerId).not.toBe(admin.id);
});
it('ignores an ownerId, slug and id supplied by the caller', async () => {
const owner = await createUser();
const impostor = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: {
name: 'Clean Slate',
workspaceId: workspace.id,
id: 'attacker-chosen-id',
ownerId: impostor.id,
slug: 'attacker-chosen-slug',
allowDownloads: true,
},
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow();
expect(stored.id).not.toBe('attacker-chosen-id');
expect(stored.ownerId).toBe(owner.id);
expect(stored.slug).toBe('clean-slate');
expect(stored.allowDownloads).toBe(false);
});
it('gives two projects with the same name distinct slugs', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
for (let index = 0; index < 3; index += 1) {
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Same Name', workspaceId: workspace.id } })
);
expect(response.status).toBe(201);
}
const slugs = (await db.project.findMany({ select: { slug: true } })).map((row) => row.slug);
expect(slugs.sort()).toEqual(['same-name', 'same-name-1', 'same-name-2']);
});
it('honours an explicit PUBLIC visibility', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Open', workspaceId: workspace.id, visibility: 'PUBLIC' },
})
);
expect(response.status).toBe(201);
expect((await db.project.findFirstOrThrow()).visibility).toBe('PUBLIC');
});
});
describe('GET /api/projects/[projectId]', () => {
it('returns 404 for an unknown project', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(getProject, apiRequest('/api/projects/nope'), {
projectId: 'nope',
});
expect(response.status).toBe(404);
});
it('returns 403 for a signed-in non-member on a PRIVATE project', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 403 for a signed-in non-member on an INVITE project', async () => {
const scenario = await seedProject({ visibility: 'INVITE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 200 to an anonymous caller on a PUBLIC project', async () => {
const scenario = await seedProject({ visibility: 'PUBLIC' });
signedOut();
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
});
it('returns 403 to the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
signedInAs(expiredOwner);
const response = await callRoute(getProject, apiRequest(`/api/projects/${project.id}`), {
projectId: project.id,
});
expect(response.status).toBe(403);
});
it.each([['limit=0'], ['limit=101'], ['offset=-1'], ['offset=10001'], ['offset=abc']])(
'rejects ?%s with 400',
async (query) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}?${query}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
}
);
it('paginates the embedded video list with limit and offset', async () => {
const scenario = await seedProject();
for (let position = 0; position < 3; position += 1) {
await createVideo({ projectId: scenario.project.id, position });
}
signedInAs(scenario.owner);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}?limit=2&offset=2`),
{ projectId: scenario.project.id }
);
const project = await readData<{ videos: Array<{ id: string }>; _count: { videos: number } }>(
response
);
expect(response.status).toBe(200);
expect(project.videos).toHaveLength(1);
expect(project._count.videos).toBe(3);
});
});
describe('PATCH /api/projects/[projectId]', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: 'Renamed' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect((await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).name).toBe(
scenario.project.name
);
});
it('returns 403 for a project COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: 'Renamed by a commentator' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect((await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).name).toBe(
scenario.project.name
);
});
it('returns 403 for an unknown project rather than 404', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
patchProject,
apiRequest('/api/projects/nope', { method: 'PATCH', body: { name: 'X' } }),
{ projectId: 'nope' }
);
expect(response.status).toBe(403);
});
it('lets a project ADMIN rename the project', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: admin.id,
role: 'ADMIN',
});
signedInAs(admin);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: ' Renamed ', description: ' new description ', allowDownloads: true },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.name).toBe('Renamed');
expect(stored.description).toBe('new description');
expect(stored.allowDownloads).toBe(true);
});
it('lets a workspace ADMIN edit a project they are not a member of', async () => {
const scenario = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { visibility: 'PUBLIC' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
expect(
(await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).visibility
).toBe('PUBLIC');
});
it.each([
[{ name: '' }, 'an empty name'],
[{ name: 'x'.repeat(101) }, 'a name over 100 characters'],
[{ description: 'x'.repeat(1001) }, 'a description over 1000 characters'],
[{ description: 5 }, 'a non-string description'],
[{ visibility: 'SEMI_PRIVATE' }, 'an unknown visibility'],
[{ allowDownloads: 'yes' }, 'a non-boolean allowDownloads'],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'PATCH', body }),
{ projectId: scenario.project.id }
);
expect(response.status, label).toBe(400);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.name).toBe(scenario.project.name);
expect(stored.visibility).toBe(scenario.project.visibility);
});
it('ignores an ownerId and a workspaceId in the body', async () => {
const scenario = await seedProject();
const other = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: {
name: 'Still Mine',
ownerId: other.owner.id,
workspaceId: other.workspace.id,
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.ownerId).toBe(scenario.owner.id);
expect(stored.workspaceId).toBe(scenario.workspace.id);
});
});
describe('DELETE /api/projects/[projectId]', () => {
beforeEach(() => {
signedOut();
});
it('returns 401 without a session', async () => {
const scenario = await seedProject();
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect(await db.project.count()).toBe(1);
});
it('returns 404 for an unknown project', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
deleteProject,
apiRequest('/api/projects/nope', { method: 'DELETE' }),
{ projectId: 'nope' }
);
expect(response.status).toBe(404);
});
// canDelete is deliberately narrower than canEdit: a project ADMIN may rename
// and configure the project but may not destroy it.
it('returns 403 for a project ADMIN', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('returns 403 for a workspace ADMIN who is not the workspace owner', async () => {
const scenario = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('deletes the project and cascades to its videos for the owner', async () => {
const scenario = await seedProject();
await createVideo({ projectId: scenario.project.id });
signedInAs(scenario.owner);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
expect(await db.project.count()).toBe(0);
expect(await db.video.count()).toBe(0);
});
it('lets the workspace owner delete a project owned by someone else', async () => {
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const projectOwner = await createUser();
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
signedInAs(workspaceOwner);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${project.id}`, { method: 'DELETE' }),
{ projectId: project.id }
);
expect(response.status).toBe(200);
expect(await db.project.count()).toBe(0);
});
});
+386
View File
@@ -0,0 +1,386 @@
// Exercises the DB-backed rate limiter against real Postgres.
//
// The counter lives in an UNLOGGED `rate_limits` table and is maintained by a
// single INSERT ... ON CONFLICT DO UPDATE whose CASE arms decide between
// "increment inside the window" and "start a new window". Neither that SQL nor
// `cleanup_rate_limits()` (a plpgsql function that only exists because
// tests/setup/db-global.ts replays it) can be checked without a database.
//
// .env.test sets DISABLE_RATE_LIMIT=true so that one test file cannot exhaust a
// window for the next one. isRateLimitDisabled() reads the variable on every
// call rather than at import, so re-enabling it per test is enough, and
// tests/setup/api.ts undoes the stub afterwards.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import {
RATE_LIMIT_CONFIGS,
checkRateLimit,
cleanupRateLimits,
getClientIp,
rateLimit,
rateLimitHeaders,
} from '@/lib/rate-limit';
import { POST as postComment } from '@/app/api/versions/[versionId]/comments/route';
import { apiRequest, callRoute } from '../helpers/request';
import { signedInAs } from '../helpers/session';
import { countRows } from '../helpers/db';
import { seedVersion } from '../factories';
const CONFIG = { windowMs: 60_000, maxRequests: 3 };
function enableRateLimiting(): void {
vi.stubEnv('DISABLE_RATE_LIMIT', 'false');
}
describe('RATE_LIMIT_CONFIGS', () => {
it('gives every action a positive window and a positive maximum', () => {
const entries = Object.entries(RATE_LIMIT_CONFIGS);
expect(entries.length).toBeGreaterThan(0);
for (const [action, config] of entries) {
expect(config.windowMs, `${action} windowMs`).toBeGreaterThan(0);
expect(config.maxRequests, `${action} maxRequests`).toBeGreaterThan(0);
}
});
});
describe('checkRateLimit while disabled', () => {
it('allows everything and writes no rows', async () => {
for (let attempt = 0; attempt < 10; attempt += 1) {
const result = await checkRateLimit('1.2.3.4', 'login', CONFIG);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(CONFIG.maxRequests);
}
expect(await countRows('rate_limits')).toBe(0);
});
});
describe('checkRateLimit', () => {
beforeEach(() => {
enableRateLimiting();
});
it('allows the first N calls and blocks the next one', async () => {
const outcomes: Array<{ allowed: boolean; remaining: number }> = [];
for (let attempt = 0; attempt < 5; attempt += 1) {
const result = await checkRateLimit('1.2.3.4', 'login', CONFIG);
outcomes.push({ allowed: result.allowed, remaining: result.remaining });
}
expect(outcomes).toEqual([
{ allowed: true, remaining: 2 },
{ allowed: true, remaining: 1 },
{ allowed: true, remaining: 0 },
{ allowed: false, remaining: 0 },
{ allowed: false, remaining: 0 },
]);
const row = await db.rateLimit.findFirstOrThrow();
expect(row.key).toBe('1.2.3.4');
expect(row.action).toBe('login');
expect(row.count).toBe(5);
});
it('keeps one row per key and action pair', async () => {
await checkRateLimit('1.2.3.4', 'login', CONFIG);
await checkRateLimit('1.2.3.4', 'login', CONFIG);
await checkRateLimit('5.6.7.8', 'login', CONFIG);
await checkRateLimit('1.2.3.4', 'register', CONFIG);
const rows = await db.rateLimit.findMany({ orderBy: [{ key: 'asc' }, { action: 'asc' }] });
expect(rows.map((row) => [row.key, row.action, row.count])).toEqual([
['1.2.3.4', 'login', 2],
['1.2.3.4', 'register', 1],
['5.6.7.8', 'login', 1],
]);
});
it('does not let one key exhaust another key budget', async () => {
for (let attempt = 0; attempt < 4; attempt += 1) {
await checkRateLimit('1.2.3.4', 'login', CONFIG);
}
expect((await checkRateLimit('1.2.3.4', 'login', CONFIG)).allowed).toBe(false);
expect((await checkRateLimit('5.6.7.8', 'login', CONFIG)).allowed).toBe(true);
});
it('does not let one action exhaust another action budget', async () => {
for (let attempt = 0; attempt < 4; attempt += 1) {
await checkRateLimit('1.2.3.4', 'login', CONFIG);
}
expect((await checkRateLimit('1.2.3.4', 'comment', CONFIG)).allowed).toBe(true);
});
it('derives resetAt from the stored window start plus the window length', async () => {
const result = await checkRateLimit('1.2.3.4', 'login', CONFIG);
const row = await db.rateLimit.findFirstOrThrow();
expect(result.resetAt.getTime()).toBe(row.windowStart.getTime() + CONFIG.windowMs);
});
// The CASE arms in the upsert: once window_start is older than the window, the
// count resets to 1 and window_start moves to now rather than incrementing.
it('starts a fresh window once the old one has expired', async () => {
for (let attempt = 0; attempt < 4; attempt += 1) {
await checkRateLimit('1.2.3.4', 'login', CONFIG);
}
expect((await checkRateLimit('1.2.3.4', 'login', CONFIG)).allowed).toBe(false);
// Age the window past its length instead of waiting a real minute.
await db.$executeRaw`
UPDATE rate_limits
SET window_start = NOW() - INTERVAL '2 minutes'
WHERE key = '1.2.3.4' AND action = 'login'
`;
const afterReset = await checkRateLimit('1.2.3.4', 'login', CONFIG);
expect(afterReset.allowed).toBe(true);
expect(afterReset.remaining).toBe(CONFIG.maxRequests - 1);
const row = await db.rateLimit.findFirstOrThrow();
expect(row.count).toBe(1);
expect(Date.now() - row.windowStart.getTime()).toBeLessThan(30_000);
});
it('keeps blocking while the window is still open', async () => {
for (let attempt = 0; attempt < 5; attempt += 1) {
await checkRateLimit('1.2.3.4', 'login', CONFIG);
}
await db.$executeRaw`
UPDATE rate_limits
SET window_start = NOW() - INTERVAL '30 seconds'
WHERE key = '1.2.3.4' AND action = 'login'
`;
expect((await checkRateLimit('1.2.3.4', 'login', CONFIG)).allowed).toBe(false);
});
it('falls back to the api config for an unknown action', async () => {
const result = await checkRateLimit('1.2.3.4', 'no-such-action');
expect(result.remaining).toBe(RATE_LIMIT_CONFIGS.api.maxRequests - 1);
});
// Defence in depth against oversized values reaching the query. The call is
// allowed but nothing is recorded, so an attacker cannot use a huge key to
// bloat the table either.
it('allows and records nothing for an over-long key or action', async () => {
const longKey = await checkRateLimit('x'.repeat(257), 'login', CONFIG);
const longAction = await checkRateLimit('1.2.3.4', 'y'.repeat(65), CONFIG);
expect(longKey.allowed).toBe(true);
expect(longAction.allowed).toBe(true);
expect(await countRows('rate_limits')).toBe(0);
});
it('records a key of exactly 255 characters, the column width', async () => {
const result = await checkRateLimit('x'.repeat(255), 'login', CONFIG);
expect(result.allowed).toBe(true);
expect(await countRows('rate_limits')).toBe(1);
});
// Documents an off-by-one, reported rather than fixed. The guard in
// lib/rate-limit.ts rejects `key.length > 256`, but rate_limits.key is
// VARCHAR(255), so a 256-character key clears the guard and then fails the
// INSERT with P2010. The catch treats any database error as "allow", so such a
// key is never counted and the limit silently stops applying to it.
//
// Not reachable from the product today: every call site builds a key from an
// IP, a user id or a 24-character hash. The failure mode is fail-open, so a
// future longer key would disable a limit rather than break a page.
it('fails open for a 256-character key instead of counting it', async () => {
// Kept to four attempts, one past the limit, because each one logs the
// swallowed Postgres error and the point is made without ten copies of it.
const key = 'x'.repeat(256);
for (let attempt = 0; attempt < 4; attempt += 1) {
const result = await checkRateLimit(key, 'login', CONFIG);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(CONFIG.maxRequests);
}
expect(await countRows('rate_limits')).toBe(0);
});
it('counts concurrent calls exactly once each', async () => {
await Promise.all(Array.from({ length: 6 }, () => checkRateLimit('1.2.3.4', 'login', CONFIG)));
expect((await db.rateLimit.findFirstOrThrow()).count).toBe(6);
});
});
describe('cleanup_rate_limits()', () => {
beforeEach(() => {
enableRateLimiting();
});
it('deletes windows older than an hour and keeps the rest', async () => {
await db.$executeRaw`
INSERT INTO rate_limits (key, action, count, window_start) VALUES
('stale', 'login', 5, NOW() - INTERVAL '61 minutes'),
('fresh', 'login', 5, NOW() - INTERVAL '59 minutes'),
('now', 'login', 1, NOW())
`;
await cleanupRateLimits();
const remaining = (await db.rateLimit.findMany({ select: { key: true } })).map(
(row) => row.key
);
expect(remaining.sort()).toEqual(['fresh', 'now']);
});
it('is safe to call against an empty table', async () => {
await cleanupRateLimits();
expect(await countRows('rate_limits')).toBe(0);
});
});
describe('rateLimit', () => {
beforeEach(() => {
enableRateLimiting();
});
it('returns null while allowed and a 429 with headers once blocked', async () => {
const request = apiRequest('/api/anything');
for (let attempt = 0; attempt < 3; attempt += 1) {
expect(await rateLimit(request, 'login', CONFIG)).toBeNull();
}
const blocked = await rateLimit(request, 'login', CONFIG);
expect(blocked?.status).toBe(429);
expect(blocked?.headers.get('X-RateLimit-Limit')).toBe('3');
expect(blocked?.headers.get('X-RateLimit-Remaining')).toBe('0');
expect(blocked?.headers.get('X-RateLimit-Reset')).toMatch(/^\d+$/);
expect(await blocked!.json()).toEqual({
error: 'Too many requests. Please try again later.',
});
});
// With TRUSTED_PROXY_MODE unset, getClientIp() collapses every caller to
// 127.0.0.1, so the limit is per process rather than per client. Pinned here
// because it is a deliberate trade-off, not an accident.
it('shares one bucket across every caller when no trusted proxy is configured', async () => {
const first = apiRequest('/api/anything', { headers: { 'x-forwarded-for': '9.9.9.9' } });
const second = apiRequest('/api/anything', { headers: { 'x-forwarded-for': '8.8.8.8' } });
expect(await rateLimit(first, 'login', CONFIG)).toBeNull();
expect(await rateLimit(second, 'login', CONFIG)).toBeNull();
expect(await rateLimit(first, 'login', CONFIG)).toBeNull();
expect((await rateLimit(second, 'login', CONFIG))?.status).toBe(429);
expect((await db.rateLimit.findFirstOrThrow()).key).toBe('127.0.0.1');
});
});
describe('getClientIp', () => {
it('returns 127.0.0.1 and ignores proxy headers with no trusted proxy mode', () => {
const request = apiRequest('/api/anything', {
headers: {
'x-forwarded-for': '203.0.113.9',
'x-real-ip': '203.0.113.9',
'cf-connecting-ip': '203.0.113.9',
},
});
expect(getClientIp(request)).toBe('127.0.0.1');
});
it('trusts cf-connecting-ip in cloudflare mode', () => {
vi.stubEnv('TRUSTED_PROXY_MODE', 'cloudflare');
const request = apiRequest('/api/anything', {
headers: { 'cf-connecting-ip': '203.0.113.9', 'x-forwarded-for': '10.0.0.1' },
});
expect(getClientIp(request)).toBe('203.0.113.9');
});
it('rejects an implausible cf-connecting-ip in cloudflare mode', () => {
vi.stubEnv('TRUSTED_PROXY_MODE', 'cloudflare');
const request = apiRequest('/api/anything', {
headers: { 'cf-connecting-ip': 'not-an-ip; drop table' },
});
expect(getClientIp(request)).toBe('127.0.0.1');
});
it('prefers x-real-ip and otherwise the last x-forwarded-for entry in nginx mode', () => {
vi.stubEnv('TRUSTED_PROXY_MODE', 'nginx');
expect(
getClientIp(
apiRequest('/api/anything', {
headers: { 'x-real-ip': '203.0.113.9', 'x-forwarded-for': '10.0.0.1' },
})
)
).toBe('203.0.113.9');
// The last entry is the one nginx appends, so a client-supplied prefix
// cannot spoof it.
expect(
getClientIp(
apiRequest('/api/anything', {
headers: { 'x-forwarded-for': 'spoofed, 198.51.100.7' },
})
)
).toBe('198.51.100.7');
});
it('ignores proxy headers for an unrecognised mode', () => {
vi.stubEnv('TRUSTED_PROXY_MODE', 'haproxy');
const request = apiRequest('/api/anything', {
headers: { 'x-real-ip': '203.0.113.9', 'cf-connecting-ip': '203.0.113.9' },
});
expect(getClientIp(request)).toBe('127.0.0.1');
});
});
describe('rateLimitHeaders', () => {
it('renders the reset time as unix seconds', () => {
const resetAt = new Date(1_700_000_123_456);
expect(rateLimitHeaders({ allowed: true, remaining: 7, resetAt }, 10)).toEqual({
'X-RateLimit-Limit': '10',
'X-RateLimit-Remaining': '7',
'X-RateLimit-Reset': '1700000123',
});
});
});
describe('rate limiting on a real route', () => {
beforeEach(() => {
enableRateLimiting();
});
it('blocks comment creation after the configured number of comments', async () => {
const scenario = await seedVersion({ duration: 600 });
signedInAs(scenario.owner);
const max = RATE_LIMIT_CONFIGS.comment.maxRequests;
const statuses: number[] = [];
for (let attempt = 0; attempt <= max; attempt += 1) {
const response = await callRoute(
postComment,
apiRequest(`/api/versions/${scenario.version.id}/comments`, {
body: { content: `comment ${attempt}`, timestamp: attempt },
}),
{ versionId: scenario.version.id }
);
statuses.push(response.status);
}
expect(statuses.slice(0, max)).toEqual(Array.from({ length: max }, () => 201));
expect(statuses[max]).toBe(429);
// The blocked request must not have written a row.
expect(await db.comment.count()).toBe(max);
expect((await db.rateLimit.findFirstOrThrow()).action).toBe('comment');
});
});
+369
View File
@@ -0,0 +1,369 @@
import { createHash } from 'node:crypto';
import bcrypt from 'bcryptjs';
import { describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { POST as register } from '@/app/api/auth/register/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { mailTo, sentMail } from '../helpers/mail';
import { signedOut } from '../helpers/session';
import { createInvitation, createUser, seedProject } from '../factories';
const INVITE_CODE = 'test-invite';
const PASSWORD = 'correct horse battery';
function registerRequest(body: unknown) {
return apiRequest('/api/auth/register', { body });
}
async function post(body: Record<string, unknown>): Promise<Response> {
signedOut();
return callRoute(register, registerRequest({ inviteCode: INVITE_CODE, ...body }));
}
describe('POST /api/auth/register', () => {
it.each([
[{ email: '[email protected]', password: PASSWORD }, 'a missing name'],
[{ name: 'A', email: '[email protected]', password: PASSWORD }, 'a one-character name'],
[{ name: 'x'.repeat(101), email: '[email protected]', password: PASSWORD }, 'a 101-character name'],
[{ name: 42, email: '[email protected]', password: PASSWORD }, 'a non-string name'],
[{ name: 'Valid Name', password: PASSWORD }, 'a missing email'],
[{ name: 'Valid Name', email: '[email protected]', password: 'short' }, 'a 5-character password'],
[
{ name: 'Valid Name', email: '[email protected]', password: 'x'.repeat(129) },
'a 129-character password',
],
[{ name: 'Valid Name', email: '[email protected]' }, 'a missing password'],
])('rejects %j with 400 (%s)', async (body, label) => {
const response = await post(body);
expect(response.status, label).toBe(400);
expect(await db.user.count()).toBe(0);
});
it.each([['no-at-sign'], ['nope@nodot'], ['double@@example.com'], ['sp [email protected]']])(
'returns 422 for the malformed address %s',
async (email) => {
const response = await post({ name: 'Valid Name', email, password: PASSWORD });
expect(response.status).toBe(422);
expect(await db.user.count()).toBe(0);
}
);
it('returns 403 when the invite code is missing', async () => {
signedOut();
const response = await callRoute(
register,
registerRequest({ name: 'Valid Name', email: '[email protected]', password: PASSWORD })
);
expect(response.status).toBe(403);
expect(await db.user.count()).toBe(0);
});
it.each([['wrong-code'], [''], ['test-invit'], ['test-invitee']])(
'returns 403 for the invite code %s',
async (inviteCode) => {
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Valid Name',
email: '[email protected]',
password: PASSWORD,
inviteCode,
})
);
expect(response.status).toBe(403);
expect(await db.user.count()).toBe(0);
}
);
it('does not require an invite code when the flag is off', async () => {
vi.stubEnv('OPENFRAME_REQUIRE_INVITE_CODE', 'false');
signedOut();
const response = await callRoute(
register,
registerRequest({ name: 'Valid Name', email: '[email protected]', password: PASSWORD })
);
expect(response.status).toBe(201);
expect(await db.user.count()).toBe(1);
});
it('creates the account with a lowercased email and a bcrypt hash', async () => {
const response = await post({
name: ' Ada Lovelace ',
email: ' [email protected] ',
password: PASSWORD,
});
const payload = await readData<{
message: string;
user: { id: string; email: string; name: string };
emailVerificationRequired: boolean;
}>(response);
expect(response.status).toBe(201);
expect(payload.emailVerificationRequired).toBe(true);
expect(payload.user.email).toBe('[email protected]');
expect(payload.user.name).toBe('Ada Lovelace');
// The response envelope must not carry the hash, let alone the password.
expect(JSON.stringify(payload)).not.toContain(PASSWORD);
expect(payload.user).not.toHaveProperty('password');
const stored = await db.user.findUniqueOrThrow({
where: { email: '[email protected]' },
});
expect(stored.name).toBe('Ada Lovelace');
expect(stored.password).not.toBe(PASSWORD);
expect(stored.password).toMatch(/^\$2[aby]\$/);
expect(await bcrypt.compare(PASSWORD, stored.password!)).toBe(true);
// SMTP is configured in .env.test, so verification is enforced.
expect(stored.emailVerified).toBeNull();
});
it('stores only the digest of the verification token and mails the raw one', async () => {
const response = await post({
name: 'Ada Lovelace',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(201);
const record = await db.verificationToken.findFirstOrThrow();
expect(record.identifier).toBe('[email protected]');
expect(record.token).toMatch(/^[0-9a-f]{64}$/);
expect(record.expires.getTime()).toBeGreaterThan(Date.now());
const mails = mailTo('[email protected]');
expect(mails).toHaveLength(1);
const rawToken = mails[0].html?.match(/token=([0-9a-f]{64})/)?.[1];
expect(rawToken).toBeTruthy();
// The stored value must be the digest, not the token itself, or a database
// leak hands out live verification links.
expect(record.token).not.toBe(rawToken);
expect(createHash('sha256').update(rawToken!).digest('hex')).toBe(record.token);
});
it('returns 409 for a duplicate email regardless of case, and does not touch the existing row', async () => {
const existing = await createUser({ email: '[email protected]', password: 'a-different-one' });
const response = await post({
name: 'Impostor',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(409);
expect(await db.user.count()).toBe(1);
const stored = await db.user.findUniqueOrThrow({ where: { id: existing.id } });
expect(stored.password).toBe(existing.password);
expect(stored.name).toBe(existing.name);
expect(sentMail()).toEqual([]);
});
it('accepts a matching invitation token instead of the invite code, and applies the membership', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
role: 'ADMIN',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Invited Person',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
const membership = await db.projectMember.findUniqueOrThrow({
where: { projectId_userId: { projectId: scenario.project.id, userId: created.id } },
});
expect(membership.role).toBe('ADMIN');
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'ACCEPTED'
);
});
it('applies a workspace invitation membership', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
email: '[email protected]',
role: 'ADMIN',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Workspace Invitee',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(
(
await db.workspaceMember.findUniqueOrThrow({
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: created.id } },
})
).role
).toBe('ADMIN');
});
// The invitation is bound to an address. Registering with a different one must
// not inherit the membership.
it('returns 403 when the invitation token was issued to a different email', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Wrong Person',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(403);
expect(await db.user.count()).toBe(1);
expect(await db.projectMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
it('returns 403 for an expired invitation token', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
expiresAt: new Date(Date.now() - 60_000),
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Late Person',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(403);
expect(await db.user.count()).toBe(1);
expect(await db.projectMember.count()).toBe(0);
});
it('returns 403 for an unknown invitation token', async () => {
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Nobody',
email: '[email protected]',
password: PASSWORD,
invitationToken: 'not-a-real-token',
})
);
expect(response.status).toBe(403);
expect(await db.user.count()).toBe(0);
});
it('returns 403 for an already accepted invitation token', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
status: 'ACCEPTED',
acceptedAt: new Date(),
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Reuser',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(403);
expect(await db.user.count()).toBe(1);
});
it('auto-verifies the email when SMTP is not configured', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
const response = await post({
name: 'Self Hosted',
email: '[email protected]',
password: PASSWORD,
});
const payload = await readData<{ emailVerificationRequired: boolean }>(response);
expect(response.status).toBe(201);
expect(payload.emailVerificationRequired).toBe(false);
const stored = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(stored.emailVerified).toBeInstanceOf(Date);
expect(await db.verificationToken.count()).toBe(0);
expect(sentMail()).toEqual([]);
});
it('reports the rate limit budget on a successful registration', async () => {
const response = await post({
name: 'Rate Limited',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(201);
expect(response.headers.get('X-RateLimit-Limit')).toBe('5');
// The exact value, not just "present": .env.test sets DISABLE_RATE_LIMIT, so
// checkRateLimit() short-circuits to a full budget. toBeTruthy() held for any
// non-empty string, including a wrong one, which left the arithmetic behind
// this header untested from here.
expect(response.headers.get('X-RateLimit-Remaining')).toBe('5');
});
});
+615
View File
@@ -0,0 +1,615 @@
import bcrypt from 'bcryptjs';
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session';
import {
DELETE as revokeShare,
GET as getShare,
PATCH as patchShare,
POST as createShare,
} from '@/app/api/projects/[projectId]/videos/[videoId]/share/route';
import { GET as watchVideo } from '@/app/api/watch/[videoId]/route';
import {
GET as listComments,
POST as postComment,
} from '@/app/api/versions/[versionId]/comments/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createShareLink,
createUser,
createVersion,
createVideo,
createWorkspace,
seedVersion,
} from '../factories';
interface SharePayload {
link: {
id: string;
token: string;
permission: string;
allowGuests: boolean;
allowDownloads: boolean;
hasPassword: boolean;
expiresAt: string | null;
} | null;
shareUrl: string | null;
}
function shareUrl(projectId: string, videoId: string): string {
return `/api/projects/${projectId}/videos/${videoId}/share`;
}
function shareCookie(videoId: string, token: string, passwordVerified = false) {
return {
[getShareSessionCookieName(videoId)]: createShareSessionValue(token, videoId, passwordVerified),
};
}
describe('share link management', () => {
it.each([
['GET', getShare],
['POST', createShare],
['PATCH', patchShare],
['DELETE', revokeShare],
] as const)('returns 401 for %s without a session', async (method, handler) => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
handler,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), {
method,
...(method === 'GET' ? {} : { body: {} }),
}),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(401);
expect(await db.shareLink.count()).toBe(0);
});
it('returns 403 for a project COMMENTATOR and creates nothing', async () => {
const scenario = await seedVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), { body: {} }),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(403);
expect(await db.shareLink.count()).toBe(0);
});
it('returns 404 when the video belongs to another project', async () => {
const mine = await seedVersion();
const theirs = await seedVersion();
signedInAs(mine.owner);
const response = await callRoute(
createShare,
apiRequest(shareUrl(mine.project.id, theirs.video.id), { body: {} }),
{ projectId: mine.project.id, videoId: theirs.video.id }
);
expect(response.status).toBe(404);
expect(await db.shareLink.count()).toBe(0);
});
it('returns 403 once the workspace owner has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
const video = await createVideo({ projectId: project.id });
signedInAs(expiredOwner);
const response = await callRoute(
createShare,
apiRequest(shareUrl(project.id, video.id), { body: {} }),
{ projectId: project.id, videoId: video.id }
);
expect(response.status).toBe(403);
});
it('creates a COMMENT link with a bcrypt-hashed password and never echoes it back', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
createShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), {
body: { password: ' correct horse ', allowGuests: false, allowDownloads: true },
}),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
const payload = await readData<SharePayload>(response);
expect(response.status).toBe(200);
expect(payload.link?.hasPassword).toBe(true);
expect(payload.link?.allowGuests).toBe(false);
expect(payload.link?.allowDownloads).toBe(true);
expect(payload.shareUrl).toContain(`shareToken=${payload.link?.token}`);
expect(JSON.stringify(payload)).not.toContain('correct horse');
const stored = await db.shareLink.findFirstOrThrow();
expect(stored.permission).toBe('COMMENT');
expect(stored.videoId).toBe(scenario.video.id);
expect(stored.passwordHash).not.toBeNull();
expect(stored.passwordHash).not.toContain('correct horse');
// Trimmed before hashing, so the untrimmed form must not verify.
expect(await bcrypt.compare('correct horse', stored.passwordHash!)).toBe(true);
expect(await bcrypt.compare(' correct horse ', stored.passwordHash!)).toBe(false);
});
it('rejects a password longer than 128 characters', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
createShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), {
body: { password: 'x'.repeat(129) },
}),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(400);
expect(await db.shareLink.count()).toBe(0);
});
it('rotates the token on a second create instead of adding a row', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const first = await readData<SharePayload>(
await callRoute(
createShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), { body: {} }),
{ projectId: scenario.project.id, videoId: scenario.video.id }
)
);
const second = await readData<SharePayload>(
await callRoute(
createShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), { body: {} }),
{ projectId: scenario.project.id, videoId: scenario.video.id }
)
);
expect(await db.shareLink.count()).toBe(1);
expect(second.link?.id).toBe(first.link?.id);
expect(second.link?.token).not.toBe(first.link?.token);
});
it('lets a workspace ADMIN manage the link for a project they are not a member of', async () => {
const scenario = await seedVersion();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
createShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), { body: {} }),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(200);
expect(await db.shareLink.count()).toBe(1);
});
it('reports no link when none exists', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const payload = await readData<SharePayload>(
await callRoute(getShare, apiRequest(shareUrl(scenario.project.id, scenario.video.id)), {
projectId: scenario.project.id,
videoId: scenario.video.id,
})
);
expect(payload).toEqual({ link: null, shareUrl: null });
});
it('ignores a project-wide VIEW link when reading the video share settings', async () => {
const scenario = await seedVersion();
await createShareLink({ projectId: scenario.project.id, permission: 'VIEW' });
await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
});
signedInAs(scenario.owner);
const payload = await readData<SharePayload>(
await callRoute(getShare, apiRequest(shareUrl(scenario.project.id, scenario.video.id)), {
projectId: scenario.project.id,
videoId: scenario.video.id,
})
);
expect(payload.link).toBeNull();
});
it('returns 404 on PATCH when there is no link yet', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
patchShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), {
method: 'PATCH',
body: { allowGuests: false },
}),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(404);
});
it('toggles allowGuests without rotating the token', async () => {
const scenario = await seedVersion();
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
allowGuests: true,
});
signedInAs(scenario.owner);
const response = await callRoute(
patchShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), {
method: 'PATCH',
body: { allowGuests: false, allowDownloads: true },
}),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const stored = await db.shareLink.findUniqueOrThrow({ where: { id: link.id } });
expect(stored.allowGuests).toBe(false);
expect(stored.allowDownloads).toBe(true);
expect(stored.token).toBe(link.token);
});
it('clears the password and rotates the token on clearPassword', async () => {
const scenario = await seedVersion();
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
password: 'secret123',
});
signedInAs(scenario.owner);
const response = await callRoute(
patchShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), {
method: 'PATCH',
body: { clearPassword: true },
}),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const stored = await db.shareLink.findUniqueOrThrow({ where: { id: link.id } });
expect(stored.passwordHash).toBeNull();
// Dropping the password must invalidate the old URL, otherwise anyone who
// already had the token silently gains unprotected access.
expect(stored.token).not.toBe(link.token);
});
it('revokes only the COMMENT link for that video', async () => {
const scenario = await seedVersion();
const otherVideo = await createVideo({ projectId: scenario.project.id });
await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
});
const viewLink = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
});
const otherLink = await createShareLink({
projectId: scenario.project.id,
videoId: otherVideo.id,
permission: 'COMMENT',
});
signedInAs(scenario.owner);
const response = await callRoute(
revokeShare,
apiRequest(shareUrl(scenario.project.id, scenario.video.id), { method: 'DELETE' }),
{ projectId: scenario.project.id, videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const remaining = (await db.shareLink.findMany({ select: { id: true } })).map((row) => row.id);
expect(remaining.sort()).toEqual([viewLink.id, otherLink.id].sort());
});
});
describe('share link enforcement on read', () => {
it('grants a guest access with a valid VIEW session', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
});
signedOut();
const response = await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, link.token),
}),
{ videoId: scenario.video.id }
);
const payload = await readData<{ canComment: boolean; canDownload: boolean }>(response);
expect(response.status).toBe(200);
// VIEW must not confer comment rights.
expect(payload.canComment).toBe(false);
expect(payload.canDownload).toBe(false);
});
it('grants comment rights only with a COMMENT link', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
allowGuests: true,
});
signedOut();
const payload = await readData<{ canComment: boolean }>(
await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, link.token),
}),
{ videoId: scenario.video.id }
)
);
expect(payload.canComment).toBe(true);
});
it('reports canDownload only when the link allows downloads', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
allowDownloads: true,
});
signedOut();
const payload = await readData<{ canDownload: boolean; canDownloadAssets: boolean }>(
await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, link.token),
}),
{ videoId: scenario.video.id }
)
);
expect(payload.canDownload).toBe(true);
expect(payload.canDownloadAssets).toBe(true);
});
it('refuses an expired link', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
expiresAt: new Date(Date.now() - 1000),
});
signedOut();
const response = await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, link.token),
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('refuses a password-protected link until the session records the password check', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
password: 'letmein',
});
signedOut();
const unverified = await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, link.token, false),
}),
{ videoId: scenario.video.id }
);
const verified = await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, link.token, true),
}),
{ videoId: scenario.video.id }
);
expect(unverified.status).toBe(403);
expect(verified.status).toBe(200);
});
it('refuses a share session whose HMAC does not verify', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
});
const tampered = createShareSessionValue(link.token, scenario.video.id, true).replace(
/.$/,
'X'
);
signedOut();
const response = await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: { [getShareSessionCookieName(scenario.video.id)]: tampered },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('refuses a project-wide link presented for a specific video', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const projectWide = await createShareLink({
projectId: scenario.project.id,
videoId: null,
permission: 'COMMENT',
});
signedOut();
const response = await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, projectWide.token),
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('refuses a link once the workspace owner loses billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
const video = await createVideo({ projectId: project.id });
await createVersion({ videoParentId: video.id });
const link = await createShareLink({
projectId: project.id,
videoId: video.id,
permission: 'VIEW',
});
signedOut();
const response = await callRoute(
watchVideo,
apiRequest(`/api/watch/${video.id}`, { cookies: shareCookie(video.id, link.token) }),
{ videoId: video.id }
);
expect(response.status).toBe(403);
});
it('lets a COMMENT-link guest post a comment but a VIEW-link guest cannot', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const viewLink = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
});
const commentLink = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
allowGuests: true,
});
signedOut();
const asViewer = await callRoute(
postComment,
apiRequest(`/api/versions/${scenario.version.id}/comments`, {
body: { content: 'hi', timestamp: 1, guestName: 'Viewer' },
cookies: shareCookie(scenario.video.id, viewLink.token),
}),
{ versionId: scenario.version.id }
);
const asCommenter = await callRoute(
postComment,
apiRequest(`/api/versions/${scenario.version.id}/comments`, {
body: { content: 'hi', timestamp: 1, guestName: 'Commenter' },
cookies: shareCookie(scenario.video.id, commentLink.token),
}),
{ versionId: scenario.version.id }
);
expect(asViewer.status).toBe(403);
expect(asCommenter.status).toBe(201);
const stored = await db.comment.findMany();
expect(stored).toHaveLength(1);
expect(stored[0].guestName).toBe('Commenter');
});
// A COMMENT link satisfies a VIEW requirement, but not the other way round.
it('accepts a COMMENT link where only VIEW is required', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
});
signedOut();
const response = await callRoute(
listComments,
apiRequest(`/api/versions/${scenario.version.id}/comments`, {
cookies: shareCookie(scenario.video.id, link.token),
}),
{ versionId: scenario.version.id }
);
expect(response.status).toBe(200);
});
it('refuses a token that does not exist', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
signedOut();
const response = await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}`, {
cookies: shareCookie(scenario.video.id, 'not-a-real-token'),
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
});
+487
View File
@@ -0,0 +1,487 @@
// Exercises lib/storage-quota.ts against real Postgres.
//
// This is the one place in the suite where the interesting behaviour lives in
// SQL rather than in TypeScript: `reserveStorageQuota` takes a per-user
// advisory transaction lock (pg_advisory_xact_lock over a 64-bit md5 hash) so
// that two simultaneous uploads see each other's in-flight reservations instead
// of both measuring the same headroom. A mocked Prisma client cannot show that
// either way, and no amount of clicking in the app can either.
//
// Note on BigInt: tsconfig targets ES2017 so a `1n` literal is a compile error.
// Always BigInt(1), and always compare BigInt against BigInt.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
import {
PLAN_STORAGE_LIMIT_BYTES,
enforceStorageQuota,
getUserStorageInfo,
getUserTotalStorageBytes,
releaseStorageReservation,
reserveStorageQuota,
} from '@/lib/storage-quota';
import { GET as getStorageSettings } from '@/app/api/settings/storage/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
createExpiredUser,
createProject,
createUploadReservation,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
seedProject,
} from '../factories';
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
function bunnyStorage(map: Record<string, number>): void {
vi.mocked(getCachedUserBunnyStorage).mockResolvedValue(map);
}
// The mock implementation is module state, so it survives afterEach. Reset it so
// no test inherits another test's Bunny figures.
beforeEach(() => {
bunnyStorage({});
});
/** Bytes of headroom left before the plan limit, as a bigint. */
function headroom(usedBytes: bigint): bigint {
return PLAN_STORAGE_LIMIT_BYTES - usedBytes;
}
describe('PLAN_STORAGE_LIMIT_BYTES', () => {
it('is 200 GiB', () => {
expect(PLAN_STORAGE_LIMIT_BYTES).toBe(BigInt(200) * GIB);
});
});
describe('getUserTotalStorageBytes', () => {
it('is zero for a user with nothing stored', async () => {
const user = await createUser();
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(0));
});
it('sums R2 image, audio and video assets billed to the user', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'R2_IMAGE',
sizeBytes: BigInt(100),
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'R2_AUDIO',
sizeBytes: BigInt(200),
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'R2_VIDEO',
sizeBytes: BigInt(400),
});
expect(await getUserTotalStorageBytes(scenario.owner.id)).toBe(BigInt(700));
});
it('ignores assets billed to somebody else and non-R2 providers', async () => {
const scenario = await seedProject();
const other = await createUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: other.id,
provider: 'R2_IMAGE',
sizeBytes: BigInt(9999),
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'YOUTUBE',
sizeBytes: BigInt(9999),
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'BUNNY',
providerVideoId: 'bunny-1',
sizeBytes: BigInt(9999),
});
expect(await getUserTotalStorageBytes(scenario.owner.id)).toBe(BigInt(0));
});
// Versions are billed through the workspace owner, not through the project
// owner or the uploader, which is what the join in the raw SQL encodes.
it('sums r2 video versions through the workspace owner', async () => {
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const projectOwner = await createUser();
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
const video = await createVideo({ projectId: project.id });
await createVersion({
videoParentId: video.id,
providerId: 'r2',
sizeBytes: BigInt(5000),
});
await createVersion({
videoParentId: video.id,
versionNumber: 2,
providerId: 'youtube',
sizeBytes: BigInt(9999),
});
expect(await getUserTotalStorageBytes(workspaceOwner.id)).toBe(BigInt(5000));
expect(await getUserTotalStorageBytes(projectOwner.id)).toBe(BigInt(0));
});
it('counts active reservations and ignores expired ones', async () => {
const user = await createUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1000) });
await createUploadReservation({
billedUserId: user.id,
sizeBytes: BigInt(500_000),
expiresInMs: -60_000,
});
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(1000));
});
it('adds the Bunny Stream bytes reported for the user', async () => {
const user = await createUser();
bunnyStorage({ [user.id]: 12_345 });
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(12_345));
});
});
describe('getUserStorageInfo', () => {
it('reports the percentage to two decimal places', async () => {
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: BigInt(50) * GIB,
});
const info = await getUserStorageInfo(user.id);
expect(info.limitBytes).toBe(PLAN_STORAGE_LIMIT_BYTES);
expect(info.usedBytes).toBe(BigInt(50) * GIB);
expect(info.percentage).toBe(25);
});
it('clamps the percentage at 100 when usage exceeds the limit', async () => {
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES * BigInt(3),
});
expect((await getUserStorageInfo(user.id)).percentage).toBe(100);
});
});
describe('enforceStorageQuota', () => {
it('allows an upload that stays under the limit', async () => {
const user = await createUser();
expect(await enforceStorageQuota(user.id, BigInt(1024))).toBeNull();
});
// The route uses `>=`, so a user sitting exactly on the limit is blocked
// rather than allowed one more byte.
it('rejects an upload that lands exactly on the limit', async () => {
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
});
const response = await enforceStorageQuota(user.id, BigInt(1024));
expect(response?.status).toBe(507);
});
it('allows an upload one byte short of the limit', async () => {
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
});
expect(await enforceStorageQuota(user.id, BigInt(1023))).toBeNull();
});
it('skips the check entirely when Stripe is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES,
});
expect(await enforceStorageQuota(user.id, PLAN_STORAGE_LIMIT_BYTES)).toBeNull();
});
});
describe('reserveStorageQuota', () => {
it('writes a reservation row billed to the user with the requested size', async () => {
const user = await createUser();
const result = await reserveStorageQuota(user.id, BigInt(4096));
expect('reservationId' in result).toBe(true);
const reservation = await db.uploadReservation.findFirstOrThrow();
expect('reservationId' in result && result.reservationId).toBe(reservation.id);
expect(reservation.billedUserId).toBe(user.id);
expect(reservation.sizeBytes).toBe(BigInt(4096));
expect(reservation.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it('returns a null reservation id and writes nothing when Stripe is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const user = await createUser();
const result = await reserveStorageQuota(user.id, BigInt(4096));
expect(result).toEqual({ reservationId: null });
expect(await db.uploadReservation.count()).toBe(0);
});
it('refuses a reservation that would cross the limit and writes no row', async () => {
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
});
const result = await reserveStorageQuota(user.id, BigInt(2048));
expect('error' in result).toBe(true);
expect('error' in result && result.error.status).toBe(507);
expect(await db.uploadReservation.count()).toBe(1);
});
it('counts committed R2 assets against the reservation', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: 'R2_VIDEO',
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(100),
});
const result = await reserveStorageQuota(scenario.owner.id, BigInt(200));
expect('error' in result).toBe(true);
expect(await db.uploadReservation.count()).toBe(0);
});
it('counts Bunny Stream bytes against the reservation', async () => {
const user = await createUser();
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
const result = await reserveStorageQuota(user.id, BigInt(2048));
expect('error' in result).toBe(true);
expect(await db.uploadReservation.count()).toBe(0);
});
it('ignores an expired reservation when computing headroom', async () => {
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
expiresInMs: -60_000,
});
const result = await reserveStorageQuota(user.id, BigInt(2048));
expect('reservationId' in result).toBe(true);
});
it('does not let one user reservations reduce another user headroom', async () => {
const heavy = await createUser();
const light = await createUser();
await createUploadReservation({
billedUserId: heavy.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
});
const result = await reserveStorageQuota(light.id, BigInt(10) * GIB);
expect('reservationId' in result).toBe(true);
});
// The reason lib/storage-quota.ts holds an advisory lock at all. Both calls
// start before either commits, so without serialisation both read the same
// "used" figure, both see enough headroom, and the user ends up over quota.
it('serialises two concurrent reservations so only one fits the remaining headroom', async () => {
const user = await createUser();
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(30) * GIB;
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
// 30 GiB of headroom, and each request wants 20 GiB.
const request = BigInt(20) * GIB;
expect(headroom(used)).toBe(BigInt(30) * GIB);
const [first, second] = await Promise.all([
reserveStorageQuota(user.id, request),
reserveStorageQuota(user.id, request),
]);
const granted = [first, second].filter((result) => 'reservationId' in result);
const refused = [first, second].filter((result) => 'error' in result);
expect(granted).toHaveLength(1);
expect(refused).toHaveLength(1);
expect((refused[0] as { error: Response }).error.status).toBe(507);
// The decisive assertion: total reserved bytes never exceed the plan limit.
const total = await db.uploadReservation.aggregate({
where: { billedUserId: user.id },
_sum: { sizeBytes: true },
});
expect(total._sum.sizeBytes).toBe(used + request);
expect(total._sum.sizeBytes! < PLAN_STORAGE_LIMIT_BYTES).toBe(true);
expect(await db.uploadReservation.count()).toBe(2);
});
it('grants both concurrent reservations when there is room for both', async () => {
const user = await createUser();
const request = BigInt(20) * GIB;
const results = await Promise.all([
reserveStorageQuota(user.id, request),
reserveStorageQuota(user.id, request),
]);
expect(results.every((result) => 'reservationId' in result)).toBe(true);
const total = await db.uploadReservation.aggregate({ _sum: { sizeBytes: true } });
expect(total._sum.sizeBytes).toBe(request * BigInt(2));
});
it('serialises five concurrent reservations, granting exactly the number that fit', async () => {
const user = await createUser();
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(50) * GIB;
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
const request = BigInt(20) * GIB;
const results = await Promise.all(
Array.from({ length: 5 }, () => reserveStorageQuota(user.id, request))
);
const granted = results.filter((result) => 'reservationId' in result);
// 50 GiB of headroom, 20 GiB each, and the check is >= so the third would
// land exactly on the limit and is refused.
expect(granted).toHaveLength(2);
const total = await db.uploadReservation.aggregate({ _sum: { sizeBytes: true } });
expect(total._sum.sizeBytes).toBe(used + request * BigInt(2));
expect(total._sum.sizeBytes! < PLAN_STORAGE_LIMIT_BYTES).toBe(true);
});
// Different users hash to different advisory lock keys, so they must not
// block each other.
it('does not serialise reservations for different users', async () => {
const first = await createUser();
const second = await createUser();
const request = BigInt(150) * GIB;
const results = await Promise.all([
reserveStorageQuota(first.id, request),
reserveStorageQuota(second.id, request),
]);
expect(results.every((result) => 'reservationId' in result)).toBe(true);
expect(await db.uploadReservation.count()).toBe(2);
});
});
describe('releaseStorageReservation', () => {
it('deletes the reservation and frees the headroom', async () => {
const user = await createUser();
const result = await reserveStorageQuota(user.id, BigInt(10) * GIB);
const reservationId = 'reservationId' in result ? result.reservationId : null;
expect(reservationId).toBeTruthy();
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(10) * GIB);
await releaseStorageReservation(reservationId);
expect(await db.uploadReservation.count()).toBe(0);
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(0));
});
it('is a no-op for a null id', async () => {
const user = await createUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1) });
await releaseStorageReservation(null);
expect(await db.uploadReservation.count()).toBe(1);
});
// The billedUserId argument scopes the delete, so a caller cannot release
// another user's reservation by guessing its id.
it('refuses to delete a reservation belonging to a different billed user', async () => {
const owner = await createUser();
const attacker = await createUser();
const reservation = await createUploadReservation({
billedUserId: owner.id,
sizeBytes: BigInt(4096),
});
await releaseStorageReservation(reservation.id, attacker.id);
expect(await db.uploadReservation.count()).toBe(1);
});
});
describe('GET /api/settings/storage', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(getStorageSettings, apiRequest('/api/settings/storage'));
expect(response.status).toBe(401);
});
it('returns 403 for a user whose billing access has lapsed', async () => {
const expired = await createExpiredUser();
signedInAs(expired);
const response = await callRoute(getStorageSettings, apiRequest('/api/settings/storage'));
expect(response.status).toBe(403);
});
it('serialises the byte counts as strings so BigInt survives JSON', async () => {
const user = await createUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: BigInt(20) * GIB,
});
signedInAs(user);
const payload = await readData<{
usedBytes: string;
limitBytes: string;
percentage: number;
}>(await callRoute(getStorageSettings, apiRequest('/api/settings/storage')));
expect(payload.usedBytes).toBe((BigInt(20) * GIB).toString());
expect(payload.limitBytes).toBe(PLAN_STORAGE_LIMIT_BYTES.toString());
expect(typeof payload.usedBytes).toBe('string');
expect(payload.percentage).toBe(10);
});
});
+493
View File
@@ -0,0 +1,493 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import { POST as stripeWebhook } from '@/app/api/stripe/webhook/route';
import { apiRequest, callRoute } from '../helpers/request';
import { createUser } from '../factories';
const CUSTOMER_ID = 'cus_test_webhook';
const ENTITLED_PRICE_ID = 'price_test_openframe_dummy';
const HOUR = 60 * 60;
function unix(offsetSeconds: number): number {
return Math.floor(Date.now() / 1000) + offsetSeconds;
}
interface SubscriptionFixture {
id: string;
customer: string;
status: string;
created: number;
current_period_end: number | null;
cancel_at_period_end: boolean;
cancel_at: number | null;
trial_end: number | null;
ended_at?: number | null;
canceled_at?: number | null;
items: { data: Array<{ price: { id: string } }> };
}
function subscription(overrides: Partial<SubscriptionFixture> = {}): SubscriptionFixture {
return {
id: 'sub_test_1',
customer: CUSTOMER_ID,
status: 'active',
created: unix(-24 * HOUR),
current_period_end: unix(30 * 24 * HOUR),
cancel_at_period_end: false,
cancel_at: null,
trial_end: null,
items: { data: [{ price: { id: ENTITLED_PRICE_ID } }] },
...overrides,
};
}
/**
* Installs a Stripe double for one test.
*
* `constructEvent` returning the event is what stands in for a valid signature;
* the default mock in tests/setup/api.ts throws, which is the invalid-signature
* case. `subscriptions.list` is what syncStripeCustomerSubscriptions re-reads,
* so it is the source of truth rather than the event body.
*/
function stubStripe(options: {
event?: unknown;
subscriptions?: SubscriptionFixture[];
constructEventThrows?: boolean;
}): { listCalls: number } {
const counters = { listCalls: 0 };
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
webhooks: {
constructEvent: vi.fn(() => {
if (options.constructEventThrows) {
throw new Error('No signatures found matching the expected signature for payload');
}
return options.event;
}),
},
subscriptions: {
list: vi.fn(async () => {
counters.listCalls += 1;
return { data: options.subscriptions ?? [] };
}),
},
});
return counters;
}
function webhookRequest(body: unknown, headers: Record<string, string> = {}) {
return apiRequest('/api/stripe/webhook', {
method: 'POST',
rawBody: JSON.stringify(body),
headers: { 'content-type': 'application/json', ...headers },
});
}
function signed(body: unknown) {
return webhookRequest(body, { 'stripe-signature': 't=1,v1=deadbeef' });
}
describe('POST /api/stripe/webhook', () => {
beforeEach(() => {
vi.mocked(getStripe as unknown as () => unknown).mockReset();
});
it('returns 400 without a stripe-signature header and never calls Stripe', async () => {
const counters = stubStripe({ event: { type: 'customer.subscription.updated' } });
await createUser({ stripeCustomerId: CUSTOMER_ID });
const response = await callRoute(stripeWebhook, webhookRequest({ type: 'anything' }));
expect(response.status).toBe(400);
expect(await response.text()).toBe('Missing Stripe signature');
expect(counters.listCalls).toBe(0);
});
it('returns 400 when the signature does not verify', async () => {
stubStripe({ constructEventThrows: true });
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const response = await callRoute(
stripeWebhook,
signed({
type: 'customer.subscription.updated',
data: { object: subscription({ status: 'active' }) },
})
);
expect(response.status).toBe(400);
expect(await response.text()).toBe('Invalid webhook signature');
// A forged event must not be able to grant a subscription.
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'FREE'
);
});
it('maps an active subscription onto the user', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
stubStripe({
event: {
id: 'evt_1',
type: 'customer.subscription.updated',
data: { object: subscription({ current_period_end: periodEnd }) },
},
subscriptions: [subscription({ current_period_end: periodEnd })],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeSubscriptionId).toBe('sub_test_1');
expect(stored.stripePriceId).toBe(ENTITLED_PRICE_ID);
expect(stored.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
expect(stored.stripeCancelAtPeriodEnd).toBe(false);
expect(stored.billingAccessEndedAt).toBeNull();
});
it('records the trial end and consumes the trial for a trialing subscription', async () => {
const trialEnd = unix(7 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
billingTrialConsumedAt: null,
});
const trialing = subscription({ status: 'trialing', trial_end: trialEnd });
stubStripe({
event: { id: 'evt_2', type: 'customer.subscription.created', data: { object: trialing } },
subscriptions: [trialing],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.created' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('TRIALING');
expect(stored.trialEndsAt?.getTime()).toBe(trialEnd * 1000);
expect(stored.billingTrialConsumedAt).toBeInstanceOf(Date);
});
it('does not grant entitlement for a subscription on a different price', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const unrelated = subscription({
items: { data: [{ price: { id: 'price_some_other_product' } }] },
});
stubStripe({
event: { id: 'evt_3', type: 'customer.subscription.updated', data: { object: unrelated } },
subscriptions: [unrelated],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
// Active in Stripe, but for a product this app does not sell.
expect(stored.subscriptionStatus).toBe('FREE');
expect(stored.stripeCurrentPeriodEnd).toBeNull();
expect(stored.billingAccessEndedAt).toBeInstanceOf(Date);
expect(stored.stripePriceId).toBe('price_some_other_product');
});
it.each([
['past_due', 'PAST_DUE'],
['unpaid', 'UNPAID'],
['incomplete', 'INCOMPLETE'],
['incomplete_expired', 'INCOMPLETE_EXPIRED'],
['canceled', 'CANCELED'],
])('maps the Stripe status %s onto %s', async (stripeStatus, expected) => {
const user = await createUser({ stripeCustomerId: CUSTOMER_ID, trialEndsAt: null });
const sub = subscription({ status: stripeStatus, current_period_end: unix(-HOUR) });
stubStripe({
event: { id: 'evt_4', type: 'customer.subscription.updated', data: { object: sub } },
subscriptions: [sub],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe(expected);
// The period already ended, so access is closed off.
expect(stored.billingAccessEndedAt).toBeInstanceOf(Date);
});
it('marks the subscription canceled when Stripe reports none left', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'ACTIVE',
stripeSubscriptionId: 'sub_test_1',
stripePriceId: ENTITLED_PRICE_ID,
stripeCurrentPeriodEnd: new Date(Date.now() + 86_400_000),
trialEndsAt: null,
});
stubStripe({
event: {
id: 'evt_5',
type: 'customer.subscription.deleted',
data: { object: subscription({ status: 'canceled' }) },
},
subscriptions: [],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.deleted' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('CANCELED');
expect(stored.stripeSubscriptionId).toBeNull();
expect(stored.stripePriceId).toBeNull();
expect(stored.stripeCurrentPeriodEnd).toBeNull();
expect(stored.trialEndsAt).toBeNull();
expect(stored.billingAccessEndedAt).toBeInstanceOf(Date);
});
// The route deliberately re-lists rather than trusting the event body, so an
// out-of-order delete of an old subscription cannot revoke a newer active one.
it('keeps the newer active subscription when an older one is deleted', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({ stripeCustomerId: CUSTOMER_ID, trialEndsAt: null });
const stale = subscription({
id: 'sub_old',
status: 'canceled',
created: unix(-90 * 24 * HOUR),
current_period_end: unix(-HOUR),
});
const live = subscription({
id: 'sub_new',
status: 'active',
created: unix(-HOUR),
current_period_end: periodEnd,
});
stubStripe({
event: { id: 'evt_6', type: 'customer.subscription.deleted', data: { object: stale } },
subscriptions: [stale, live],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.deleted' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeSubscriptionId).toBe('sub_new');
expect(stored.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
});
it('syncs on a completed subscription checkout', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const counters = stubStripe({
event: {
id: 'evt_7',
type: 'checkout.session.completed',
data: { object: { mode: 'subscription', customer: CUSTOMER_ID } },
},
subscriptions: [subscription({ current_period_end: periodEnd })],
});
const response = await callRoute(stripeWebhook, signed({ type: 'checkout.session.completed' }));
expect(response.status).toBe(200);
expect(counters.listCalls).toBe(1);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'ACTIVE'
);
});
it('ignores a one-off payment checkout', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const counters = stubStripe({
event: {
id: 'evt_8',
type: 'checkout.session.completed',
data: { object: { mode: 'payment', customer: CUSTOMER_ID } },
},
subscriptions: [subscription()],
});
const response = await callRoute(stripeWebhook, signed({ type: 'checkout.session.completed' }));
expect(response.status).toBe(200);
expect(counters.listCalls).toBe(0);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'FREE'
);
});
it('acknowledges an unhandled event type without touching any user', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const counters = stubStripe({
event: { id: 'evt_9', type: 'invoice.payment_succeeded', data: { object: {} } },
subscriptions: [subscription()],
});
const response = await callRoute(stripeWebhook, signed({ type: 'invoice.payment_succeeded' }));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ received: true });
expect(counters.listCalls).toBe(0);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'FREE'
);
});
it('acknowledges an event for a customer with no local user and writes nothing', async () => {
stubStripe({
event: {
id: 'evt_10',
type: 'customer.subscription.updated',
data: { object: subscription({ customer: 'cus_unknown' }) },
},
subscriptions: [subscription({ customer: 'cus_unknown' })],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
expect(await db.user.count()).toBe(0);
});
it('is idempotent when the same event is replayed', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
billingTrialConsumedAt: null,
});
const sub = subscription({
status: 'trialing',
trial_end: unix(7 * 24 * HOUR),
current_period_end: periodEnd,
});
stubStripe({
event: { id: 'evt_11', type: 'customer.subscription.updated', data: { object: sub } },
subscriptions: [sub],
});
const first = await callRoute(stripeWebhook, signed({ type: 'customer.subscription.updated' }));
const afterFirst = await db.user.findUniqueOrThrow({ where: { id: user.id } });
const second = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
const afterSecond = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(afterSecond.subscriptionStatus).toBe(afterFirst.subscriptionStatus);
expect(afterSecond.stripeSubscriptionId).toBe(afterFirst.stripeSubscriptionId);
expect(afterSecond.stripeCurrentPeriodEnd?.getTime()).toBe(
afterFirst.stripeCurrentPeriodEnd?.getTime()
);
expect(afterSecond.trialEndsAt?.getTime()).toBe(afterFirst.trialEndsAt?.getTime());
// The first sync stamps the trial as consumed; a replay must not push it
// forward, or a user could win a fresh trial by resending a webhook.
expect(afterSecond.billingTrialConsumedAt?.getTime()).toBe(
afterFirst.billingTrialConsumedAt?.getTime()
);
});
it('records a scheduled cancellation without revoking access', async () => {
const periodEnd = unix(15 * 24 * HOUR);
const user = await createUser({ stripeCustomerId: CUSTOMER_ID, trialEndsAt: null });
const sub = subscription({
cancel_at_period_end: true,
cancel_at: periodEnd,
current_period_end: periodEnd,
});
stubStripe({
event: { id: 'evt_12', type: 'customer.subscription.updated', data: { object: sub } },
subscriptions: [sub],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeCancelAtPeriodEnd).toBe(true);
expect(stored.stripeCancelAt?.getTime()).toBe(periodEnd * 1000);
expect(stored.billingAccessEndedAt).toBeNull();
});
it('returns 500 when the sync itself fails', async () => {
await createUser({ stripeCustomerId: CUSTOMER_ID });
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
webhooks: {
constructEvent: vi.fn(() => ({
id: 'evt_13',
type: 'customer.subscription.updated',
data: { object: subscription() },
})),
},
subscriptions: {
list: vi.fn(async () => {
throw new Error('Stripe is down');
}),
},
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
// A 500 tells Stripe to retry, which is the correct behaviour for a
// transient upstream failure.
expect(response.status).toBe(500);
});
});
File diff suppressed because it is too large Load Diff
+969
View File
@@ -0,0 +1,969 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { GET as listVideos, POST as addVideo } from '@/app/api/projects/[projectId]/videos/route';
import { POST as bulkDelete } from '@/app/api/projects/[projectId]/videos/bulk-delete/route';
import {
GET as listMoveTargets,
POST as moveVideos,
} from '@/app/api/projects/[projectId]/videos/move/route';
import {
DELETE as cancelR2Upload,
POST as initR2Upload,
} from '@/app/api/projects/[projectId]/videos/r2-init/route';
import { POST as completeR2Upload } from '@/app/api/projects/[projectId]/videos/r2-complete/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createProject,
createShareLink,
createUser,
createVersion,
createVideo,
createWorkspace,
seedProject,
seedVersion,
} from '../factories';
function videosUrl(projectId: string): string {
return `/api/projects/${projectId}/videos`;
}
/** Turns on the self-hosted direct-upload path for a single test. */
function enableS3VideoUploads(): void {
vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', 'true');
vi.stubEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', 'false');
vi.stubEnv('R2_ACCESS_KEY_ID', 'test-access-key');
vi.stubEnv('R2_SECRET_ACCESS_KEY', 'test-secret-key');
vi.stubEnv('R2_BUCKET_NAME', 'openframe-test');
vi.stubEnv('R2_ACCOUNT_ID', 'test-account');
}
describe('GET /api/projects/[projectId]/videos', () => {
it('returns 404 for an unknown project', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(listVideos, apiRequest(videosUrl('nope')), {
projectId: 'nope',
});
expect(response.status).toBe(404);
});
it('returns 403 to an anonymous caller on a PRIVATE project', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
signedOut();
const response = await callRoute(listVideos, apiRequest(videosUrl(scenario.project.id)), {
projectId: scenario.project.id,
});
expect(response.status).toBe(403);
});
it('lists videos in position order with the active version only', async () => {
const scenario = await seedProject();
const second = await createVideo({ projectId: scenario.project.id, position: 1 });
const first = await createVideo({ projectId: scenario.project.id, position: 0 });
await createVersion({ videoParentId: first.id, versionNumber: 1, isActive: false });
const active = await createVersion({
videoParentId: first.id,
versionNumber: 2,
isActive: true,
});
signedInAs(scenario.owner);
const payload = await readData<{
videos: Array<{ id: string; versions: Array<{ id: string }>; _count: { versions: number } }>;
}>(
await callRoute(listVideos, apiRequest(videosUrl(scenario.project.id)), {
projectId: scenario.project.id,
})
);
expect(payload.videos.map((entry) => entry.id)).toEqual([first.id, second.id]);
expect(payload.videos[0].versions.map((entry) => entry.id)).toEqual([active.id]);
expect(payload.videos[0]._count.versions).toBe(2);
});
});
describe('POST /api/projects/[projectId]/videos', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
addVideo,
apiRequest(videosUrl(scenario.project.id), {
body: { title: 'X', videoUrl: 'https://www.youtube.com/watch?v=abc' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect(await db.video.count()).toBe(0);
});
it('returns 403 for a project COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
addVideo,
apiRequest(videosUrl(scenario.project.id), {
body: { title: 'X', videoUrl: 'https://www.youtube.com/watch?v=abc' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.video.count()).toBe(0);
});
it.each([
[{ videoUrl: 'https://www.youtube.com/watch?v=abc' }, 'a missing title'],
[{ title: 'X' }, 'a missing videoUrl'],
[{ title: 'X', videoUrl: 'javascript:alert(1)' }, 'a javascript: URL'],
[{ title: 'X', videoUrl: 'data:text/html,<script>' }, 'a data: URL'],
[{ title: 'X', videoUrl: 'file:///etc/passwd' }, 'a file: URL'],
[{ title: 'X', videoUrl: 'not a url at all' }, 'an unparseable URL'],
[
{
title: 'X',
videoUrl: 'https://www.youtube.com/watch?v=abc',
thumbnailUrl: 'javascript:alert(1)',
},
'a javascript: thumbnail',
],
[
{ title: 'X', videoUrl: 'https://www.youtube.com/watch?v=abc', thumbnailUrl: '/etc/passwd' },
'a traversal-shaped thumbnail path',
],
[
{ title: 'X', videoUrl: '/api/upload/video/abc.mp4', providerId: 'r2' },
'an r2 upload with no objectKey',
],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
addVideo,
apiRequest(videosUrl(scenario.project.id), { body }),
{ projectId: scenario.project.id }
);
expect(response.status, label).toBe(400);
expect(await db.video.count()).toBe(0);
});
it('rejects an r2 video whose url is not an upload path', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
addVideo,
apiRequest(videosUrl(scenario.project.id), {
body: {
title: 'X',
providerId: 'r2',
videoUrl: 'https://evil.example.com/video.mp4',
objectKey: 'videos/x.mp4',
uploadToken: 'nope',
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
expect(await db.video.count()).toBe(0);
});
it('rejects a bunny video without a valid upload token', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
addVideo,
apiRequest(videosUrl(scenario.project.id), {
body: {
title: 'X',
providerId: 'bunny',
videoUrl: 'https://iframe.mediadelivery.net/play/1/abc',
videoId: 'abc',
uploadToken: 'forged.token',
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.video.count()).toBe(0);
});
it('creates the video with version 1 and appends it after the existing videos', async () => {
const scenario = await seedProject();
await createVideo({ projectId: scenario.project.id, position: 0 });
await createVideo({ projectId: scenario.project.id, position: 7 });
signedInAs(scenario.owner);
const response = await callRoute(
addVideo,
apiRequest(videosUrl(scenario.project.id), {
body: {
title: ' New Cut ',
description: ' a description ',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoId: 'dQw4w9WgXcQ',
duration: 212,
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(201);
const created = await db.video.findFirstOrThrow({
where: { title: 'New Cut' },
include: { versions: true },
});
expect(created.description).toBe('a description');
expect(created.position).toBe(8);
expect(created.projectId).toBe(scenario.project.id);
expect(created.versions).toHaveLength(1);
expect(created.versions[0].versionNumber).toBe(1);
expect(created.versions[0].providerId).toBe('youtube');
expect(created.versions[0].videoId).toBe('dQw4w9WgXcQ');
expect(created.versions[0].duration).toBe(212);
expect(created.versions[0].isActive).toBe(true);
expect(created.versions[0].sizeBytes).toBe(BigInt(0));
});
it('lets a project ADMIN add a video, lowercasing the provider id', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
addVideo,
apiRequest(videosUrl(scenario.project.id), {
body: {
title: 'Admin Cut',
videoUrl: 'https://vimeo.com/12345',
providerId: ' VIMEO ',
videoId: '12345',
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(201);
expect((await db.videoVersion.findFirstOrThrow()).providerId).toBe('vimeo');
});
});
describe('POST /api/projects/[projectId]/videos/bulk-delete', () => {
it('returns 401 without a session', async () => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
bulkDelete,
apiRequest(`${videosUrl(scenario.project.id)}/bulk-delete`, {
body: { videoIds: [scenario.video.id] },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect(await db.video.count()).toBe(1);
});
it('returns 403 for a project COMMENTATOR', async () => {
const scenario = await seedVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
bulkDelete,
apiRequest(`${videosUrl(scenario.project.id)}/bulk-delete`, {
body: { videoIds: [scenario.video.id] },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.video.count()).toBe(1);
});
it.each([
[{ videoIds: [] }, 'an empty list'],
[{ videoIds: 'not-an-array' }, 'a non-array'],
[{}, 'a missing videoIds'],
[{ videoIds: ['ok', ''] }, 'a blank id'],
[{ videoIds: [1, 2] }, 'non-string ids'],
[{ videoIds: Array.from({ length: 51 }, (_unused, index) => `id-${index}`) }, '51 ids'],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
bulkDelete,
apiRequest(`${videosUrl(scenario.project.id)}/bulk-delete`, { body }),
{ projectId: scenario.project.id }
);
expect(response.status, label).toBe(400);
expect(await db.video.count()).toBe(1);
});
// The whole point of the route's id check: a project admin must not be able
// to delete a video out of a project they have nothing to do with.
it('refuses a batch containing a video from another project and deletes nothing', async () => {
const mine = await seedVersion();
const theirs = await seedVersion();
signedInAs(mine.owner);
const response = await callRoute(
bulkDelete,
apiRequest(`${videosUrl(mine.project.id)}/bulk-delete`, {
body: { videoIds: [mine.video.id, theirs.video.id] },
}),
{ projectId: mine.project.id }
);
expect(response.status).toBe(400);
expect(await db.video.count()).toBe(2);
});
it('deletes the selected videos and cascades to versions', async () => {
const scenario = await seedVersion();
const second = await createVideo({ projectId: scenario.project.id, position: 1 });
await createVersion({ videoParentId: second.id });
const survivor = await createVideo({ projectId: scenario.project.id, position: 2 });
signedInAs(scenario.owner);
const response = await callRoute(
bulkDelete,
apiRequest(`${videosUrl(scenario.project.id)}/bulk-delete`, {
body: { videoIds: [scenario.video.id, second.id, scenario.video.id] },
}),
{ projectId: scenario.project.id }
);
const payload = await readData<{ deletedCount: number }>(response);
expect(response.status).toBe(200);
expect(payload.deletedCount).toBe(2);
expect((await db.video.findMany({ select: { id: true } })).map((row) => row.id)).toEqual([
survivor.id,
]);
expect(await db.videoVersion.count()).toBe(0);
});
});
describe('video move', () => {
it('returns 401 for the target list without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
listMoveTargets,
apiRequest(`${videosUrl(scenario.project.id)}/move`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
});
it('lists every other project in the workspace for a workspace ADMIN', async () => {
const scenario = await seedProject();
const sibling = await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
const elsewhere = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const payload = await readData<{ projects: Array<{ id: string }> }>(
await callRoute(listMoveTargets, apiRequest(`${videosUrl(scenario.project.id)}/move`), {
projectId: scenario.project.id,
})
);
expect(payload.projects.map((entry) => entry.id)).toEqual([sibling.id]);
expect(payload.projects.map((entry) => entry.id)).not.toContain(elsewhere.project.id);
});
it('limits the target list to projects a project ADMIN can manage', async () => {
const scenario = await seedProject();
const manageable = await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
await createProject({ ownerId: scenario.owner.id, workspaceId: scenario.workspace.id });
const projectAdmin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: projectAdmin.id,
role: 'ADMIN',
});
await addProjectMember({ projectId: manageable.id, userId: projectAdmin.id, role: 'ADMIN' });
signedInAs(projectAdmin);
const payload = await readData<{ projects: Array<{ id: string }> }>(
await callRoute(listMoveTargets, apiRequest(`${videosUrl(scenario.project.id)}/move`), {
projectId: scenario.project.id,
})
);
expect(payload.projects.map((entry) => entry.id)).toEqual([manageable.id]);
});
it.each([
[{ videoIds: [], targetProjectId: 'x' }, 'an empty video list'],
[{ videoIds: ['a'] }, 'a missing targetProjectId'],
[{ videoIds: ['a'], targetProjectId: ' ' }, 'a blank targetProjectId'],
[{ videoIds: [''], targetProjectId: 'x' }, 'a blank video id'],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
moveVideos,
apiRequest(`${videosUrl(scenario.project.id)}/move`, { body }),
{ projectId: scenario.project.id }
);
expect(response.status, label).toBe(400);
expect((await db.video.findUniqueOrThrow({ where: { id: scenario.video.id } })).projectId).toBe(
scenario.project.id
);
});
it('refuses a move into the same project', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
moveVideos,
apiRequest(`${videosUrl(scenario.project.id)}/move`, {
body: { videoIds: [scenario.video.id], targetProjectId: scenario.project.id },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
});
it('refuses a move across workspaces', async () => {
const source = await seedVersion();
const target = await seedProject();
signedInAs(source.owner);
const response = await callRoute(
moveVideos,
apiRequest(`${videosUrl(source.project.id)}/move`, {
body: { videoIds: [source.video.id], targetProjectId: target.project.id },
}),
{ projectId: source.project.id }
);
expect(response.status).toBe(400);
expect((await db.video.findUniqueOrThrow({ where: { id: source.video.id } })).projectId).toBe(
source.project.id
);
});
// The destination permission check. A project ADMIN of the source project has
// canEdit there but not necessarily in the destination.
it('refuses a move into a project the caller cannot manage', async () => {
const scenario = await seedVersion();
const target = await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
});
const projectAdmin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: projectAdmin.id,
role: 'ADMIN',
});
await addProjectMember({
projectId: target.id,
userId: projectAdmin.id,
role: 'COMMENTATOR',
});
signedInAs(projectAdmin);
const response = await callRoute(
moveVideos,
apiRequest(`${videosUrl(scenario.project.id)}/move`, {
body: { videoIds: [scenario.video.id], targetProjectId: target.id },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect((await db.video.findUniqueOrThrow({ where: { id: scenario.video.id } })).projectId).toBe(
scenario.project.id
);
});
it('refuses a move of a video that is not in the source project', async () => {
const source = await seedVersion();
const target = await createProject({
ownerId: source.owner.id,
workspaceId: source.workspace.id,
});
const foreign = await seedVersion();
signedInAs(source.owner);
const response = await callRoute(
moveVideos,
apiRequest(`${videosUrl(source.project.id)}/move`, {
body: { videoIds: [foreign.video.id], targetProjectId: target.id },
}),
{ projectId: source.project.id }
);
expect(response.status).toBe(400);
expect((await db.video.findUniqueOrThrow({ where: { id: foreign.video.id } })).projectId).toBe(
foreign.project.id
);
});
it('moves the videos, appends their positions and repoints their share links', async () => {
const source = await seedVersion();
const target = await createProject({
ownerId: source.owner.id,
workspaceId: source.workspace.id,
});
await createVideo({ projectId: target.id, position: 4 });
const secondVideo = await createVideo({ projectId: source.project.id, position: 1 });
const link = await createShareLink({
projectId: source.project.id,
videoId: source.video.id,
permission: 'COMMENT',
});
signedInAs(source.owner);
const response = await callRoute(
moveVideos,
apiRequest(`${videosUrl(source.project.id)}/move`, {
body: {
videoIds: [source.video.id, secondVideo.id],
targetProjectId: target.id,
},
}),
{ projectId: source.project.id }
);
const payload = await readData<{ movedCount: number; targetProjectId: string }>(response);
expect(response.status).toBe(200);
expect(payload).toMatchObject({ movedCount: 2, targetProjectId: target.id });
const moved = await db.video.findUniqueOrThrow({ where: { id: source.video.id } });
const movedSecond = await db.video.findUniqueOrThrow({ where: { id: secondVideo.id } });
expect(moved.projectId).toBe(target.id);
expect(movedSecond.projectId).toBe(target.id);
expect(moved.position).toBe(5);
expect(movedSecond.position).toBe(6);
// A stale projectId on the share link would let the link resolve against
// the old project and fail validateShareLinkAccess.
expect((await db.shareLink.findUniqueOrThrow({ where: { id: link.id } })).projectId).toBe(
target.id
);
});
});
describe('R2 video upload session lifecycle', () => {
beforeEach(() => {
signedOut();
});
it('returns 400 when self-hosted S3 uploads are disabled', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
expect(await db.videoUploadSession.count()).toBe(0);
});
it('returns 403 for a project COMMENTATOR', async () => {
enableS3VideoUploads();
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.videoUploadSession.count()).toBe(0);
expect(await db.uploadReservation.count()).toBe(0);
});
it.each([
[{ sizeBytes: '1024', contentType: 'video/mp4' }, 'a missing fileName'],
[{ fileName: 'clip.mp4', sizeBytes: '0', contentType: 'video/mp4' }, 'a zero size'],
[{ fileName: 'clip.mp4', sizeBytes: '-5', contentType: 'video/mp4' }, 'a negative size'],
[{ fileName: 'clip.mp4', sizeBytes: 'huge', contentType: 'video/mp4' }, 'an unparseable size'],
[
{ fileName: 'clip.exe', sizeBytes: '1024', contentType: 'application/x-msdownload' },
'a non-video type',
],
[
{ fileName: 'clip.mp4', sizeBytes: '99999999999999', contentType: 'video/mp4' },
'a size over the configured maximum',
],
])('rejects %j with 400 (%s)', async (body, label) => {
enableS3VideoUploads();
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, { body }),
{ projectId: scenario.project.id }
);
expect(response.status, label).toBe(400);
expect(await db.videoUploadSession.count()).toBe(0);
expect(await db.uploadReservation.count()).toBe(0);
});
it('creates an INITIATED session with a quota reservation billed to the workspace owner', async () => {
enableS3VideoUploads();
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const projectOwner = await createUser();
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
await addProjectMember({ projectId: project.id, userId: projectOwner.id, role: 'ADMIN' });
signedInAs(projectOwner);
const response = await callRoute(
initR2Upload,
apiRequest(`${videosUrl(project.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '2048', contentType: 'video/mp4' },
}),
{ projectId: project.id }
);
const payload = await readData<{
presignedPutUrl: string;
objectKey: string;
proxyUrl: string;
uploadToken: string;
thumbnailObjectKey: string;
thumbnailProxyUrl: string;
contentType: string;
multipart: unknown;
}>(response);
expect(response.status).toBe(200);
expect(payload.objectKey).toMatch(/^videos\/[0-9a-f-]{36}\.mp4$/);
expect(payload.proxyUrl).toBe(`/api/upload/video/${payload.objectKey.slice('videos/'.length)}`);
expect(payload.thumbnailObjectKey).toMatch(/^images\/[0-9a-f-]{36}\.jpg$/);
expect(payload.contentType).toBe('video/mp4');
expect(payload.multipart).toBeNull();
expect(payload.presignedPutUrl).toContain(payload.objectKey);
const session = await db.videoUploadSession.findFirstOrThrow();
expect(session.status).toBe('INITIATED');
expect(session.userId).toBe(projectOwner.id);
expect(session.projectId).toBe(project.id);
// Storage is billed to the workspace owner, not to whoever pressed upload.
expect(session.billedUserId).toBe(workspaceOwner.id);
expect(session.objectKey).toBe(payload.objectKey);
expect(session.declaredSizeBytes).toBe(BigInt(2048));
expect(session.multipartUploadId).toBeNull();
expect(session.consumedAt).toBeNull();
expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now());
const reservation = await db.uploadReservation.findFirstOrThrow();
expect(reservation.id).toBe(session.reservationId);
expect(reservation.billedUserId).toBe(workspaceOwner.id);
// The declared size plus the 512 KiB thumbnail headroom.
expect(reservation.sizeBytes).toBe(BigInt(2048) + BigInt(512 * 1024));
});
it('switches to multipart above the threshold and records the upload id', async () => {
enableS3VideoUploads();
vi.stubEnv('OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES', '1024');
vi.stubEnv('OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES', String(5 * 1024 * 1024));
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: {
fileName: 'clip.mp4',
sizeBytes: String(12 * 1024 * 1024),
contentType: 'video/mp4',
},
}),
{ projectId: scenario.project.id }
);
const payload = await readData<{
presignedPutUrl: string;
multipart: { uploadId: string; partSizeBytes: number; parts: Array<{ partNumber: number }> };
}>(response);
expect(response.status).toBe(200);
expect(payload.presignedPutUrl).toBe('');
expect(payload.multipart.uploadId).toBe('test-multipart-upload-id');
expect(payload.multipart.partSizeBytes).toBe(5 * 1024 * 1024);
expect(payload.multipart.parts.map((part) => part.partNumber)).toEqual([1, 2, 3]);
expect((await db.videoUploadSession.findFirstOrThrow()).multipartUploadId).toBe(
'test-multipart-upload-id'
);
});
it('refuses a completion presenting a forged upload token', async () => {
enableS3VideoUploads();
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
completeR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-complete`, {
body: {
objectKey: 'videos/11111111-1111-4111-8111-111111111111.mp4',
uploadToken: 'forged.token',
parts: [{ partNumber: 1, etag: 'etag-1' }],
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('refuses a completion whose parts list is malformed', async () => {
enableS3VideoUploads();
vi.stubEnv('OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES', '1024');
const scenario = await seedProject();
signedInAs(scenario.owner);
const init = await readData<{ objectKey: string; uploadToken: string }>(
await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: {
fileName: 'clip.mp4',
sizeBytes: String(6 * 1024 * 1024),
contentType: 'video/mp4',
},
}),
{ projectId: scenario.project.id }
)
);
for (const parts of [
[],
[{ partNumber: 0, etag: 'x' }],
[{ partNumber: 1, etag: '' }],
'nope',
]) {
const response = await callRoute(
completeR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-complete`, {
body: { objectKey: init.objectKey, uploadToken: init.uploadToken, parts },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
}
// The session must survive a rejected completion attempt.
expect((await db.videoUploadSession.findFirstOrThrow()).status).toBe('INITIATED');
});
it('completes a multipart upload and leaves the session open for finalisation', async () => {
enableS3VideoUploads();
vi.stubEnv('OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES', '1024');
const scenario = await seedProject();
signedInAs(scenario.owner);
const init = await readData<{ objectKey: string; uploadToken: string; proxyUrl: string }>(
await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: {
fileName: 'clip.mp4',
sizeBytes: String(6 * 1024 * 1024),
contentType: 'video/mp4',
},
}),
{ projectId: scenario.project.id }
)
);
const response = await callRoute(
completeR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-complete`, {
body: {
objectKey: init.objectKey,
uploadToken: init.uploadToken,
parts: [{ partNumber: 1, etag: 'etag-1' }],
},
}),
{ projectId: scenario.project.id }
);
const payload = await readData<{ objectKey: string; proxyUrl: string }>(response);
expect(response.status).toBe(200);
expect(payload.objectKey).toBe(init.objectKey);
expect(payload.proxyUrl).toBe(init.proxyUrl);
// r2-complete only assembles the object. POST /videos is what consumes the
// session and creates the row.
expect((await db.videoUploadSession.findFirstOrThrow()).status).toBe('INITIATED');
});
it('cancels a pending upload and releases the reservation', async () => {
enableS3VideoUploads();
const scenario = await seedProject();
signedInAs(scenario.owner);
const init = await readData<{ objectKey: string; uploadToken: string }>(
await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '2048', contentType: 'video/mp4' },
}),
{ projectId: scenario.project.id }
)
);
expect(await db.uploadReservation.count()).toBe(1);
const response = await callRoute(
cancelR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
method: 'DELETE',
body: { objectKey: init.objectKey, uploadToken: init.uploadToken },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const session = await db.videoUploadSession.findFirstOrThrow();
expect(session.status).toBe('CANCELLED');
expect(session.consumedAt).toBeInstanceOf(Date);
// Leaving the reservation behind would eat the user's quota for the TTL.
expect(await db.uploadReservation.count()).toBe(0);
});
it('refuses to cancel the same session twice', async () => {
enableS3VideoUploads();
const scenario = await seedProject();
signedInAs(scenario.owner);
const init = await readData<{ objectKey: string; uploadToken: string }>(
await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '2048', contentType: 'video/mp4' },
}),
{ projectId: scenario.project.id }
)
);
const first = await callRoute(
cancelR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
method: 'DELETE',
body: { objectKey: init.objectKey, uploadToken: init.uploadToken },
}),
{ projectId: scenario.project.id }
);
const second = await callRoute(
cancelR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
method: 'DELETE',
body: { objectKey: init.objectKey, uploadToken: init.uploadToken },
}),
{ projectId: scenario.project.id }
);
expect(first.status).toBe(200);
expect(second.status).toBe(403);
});
// The session is keyed on the user who started it, so another admin of the
// same project cannot consume or cancel someone else's upload token.
it('refuses a cancellation from a different user holding the same token', async () => {
enableS3VideoUploads();
const scenario = await seedProject();
const otherAdmin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: otherAdmin.id,
role: 'ADMIN',
});
signedInAs(scenario.owner);
const init = await readData<{ objectKey: string; uploadToken: string }>(
await callRoute(
initR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '2048', contentType: 'video/mp4' },
}),
{ projectId: scenario.project.id }
)
);
signedInAs(otherAdmin);
const response = await callRoute(
cancelR2Upload,
apiRequest(`${videosUrl(scenario.project.id)}/r2-init`, {
method: 'DELETE',
body: { objectKey: init.objectKey, uploadToken: init.uploadToken },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect((await db.videoUploadSession.findFirstOrThrow()).status).toBe('INITIATED');
});
});
+712
View File
@@ -0,0 +1,712 @@
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session';
import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-upload-token';
import { GET as watchVideo } from '@/app/api/watch/[videoId]/route';
import { GET as getProgress, POST as saveProgress } from '@/app/api/watch/[videoId]/progress/route';
import { POST as issueUploadToken } from '@/app/api/watch/[videoId]/upload-token/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createComment,
createExpiredUser,
createProject,
createShareLink,
createUser,
createVersion,
createVideo,
createWorkspace,
seedVersion,
} from '../factories';
const ORIGIN = 'http://localhost:3000';
function shareCookie(videoId: string, token: string) {
return {
[getShareSessionCookieName(videoId)]: createShareSessionValue(token, videoId, false),
};
}
describe('GET /api/watch/[videoId]', () => {
it('returns 404 for an unknown video', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(watchVideo, apiRequest('/api/watch/nope'), {
videoId: 'nope',
});
expect(response.status).toBe(404);
});
it('returns 403 to an anonymous caller on a PRIVATE video', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
signedOut();
const response = await callRoute(watchVideo, apiRequest(`/api/watch/${scenario.video.id}`), {
videoId: scenario.video.id,
});
expect(response.status).toBe(403);
});
it('returns 403 to a signed-in stranger on an INVITE video', async () => {
const scenario = await seedVersion({ visibility: 'INVITE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(watchVideo, apiRequest(`/api/watch/${scenario.video.id}`), {
videoId: scenario.video.id,
});
expect(response.status).toBe(403);
});
it('serves an anonymous caller a PUBLIC video with read-only capabilities', async () => {
const scenario = await seedVersion({ visibility: 'PUBLIC' });
signedOut();
const response = await callRoute(watchVideo, apiRequest(`/api/watch/${scenario.video.id}`), {
videoId: scenario.video.id,
});
const payload = await readData<{
isAuthenticated: boolean;
currentUserId: string | null;
canComment: boolean;
canManageTags: boolean;
canResolveComments: boolean;
canShareVideo: boolean;
canDownload: boolean;
project: { name: string; ownerId: string };
}>(response);
expect(response.status).toBe(200);
expect(payload.isAuthenticated).toBe(false);
expect(payload.currentUserId).toBeNull();
// computeProjectAccess grants hasAccess on a PUBLIC project, and the route
// maps that straight onto canComment. Recorded as current behaviour; see
// the report.
expect(payload.canComment).toBe(true);
expect(payload.canManageTags).toBe(false);
expect(payload.canResolveComments).toBe(false);
expect(payload.canShareVideo).toBe(false);
expect(payload.canDownload).toBe(false);
expect(payload.project.ownerId).toBe(scenario.owner.id);
});
it('grants the full capability set to the project owner', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const payload = await readData<{
canManageTags: boolean;
canResolveComments: boolean;
canShareVideo: boolean;
currentUserId: string;
}>(
await callRoute(watchVideo, apiRequest(`/api/watch/${scenario.video.id}`), {
videoId: scenario.video.id,
})
);
expect(payload.canManageTags).toBe(true);
expect(payload.canResolveComments).toBe(true);
expect(payload.canShareVideo).toBe(true);
expect(payload.currentUserId).toBe(scenario.owner.id);
});
it('gives a COMMENTATOR member access without management rights', async () => {
const scenario = await seedVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const payload = await readData<{
canComment: boolean;
canManageTags: boolean;
canShareVideo: boolean;
}>(
await callRoute(watchVideo, apiRequest(`/api/watch/${scenario.video.id}`), {
videoId: scenario.video.id,
})
);
expect(payload.canComment).toBe(true);
expect(payload.canManageTags).toBe(false);
expect(payload.canShareVideo).toBe(false);
});
it('gives a workspace ADMIN management rights on a project they never joined', async () => {
const scenario = await seedVersion();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const payload = await readData<{ canManageTags: boolean; canShareVideo: boolean }>(
await callRoute(watchVideo, apiRequest(`/api/watch/${scenario.video.id}`), {
videoId: scenario.video.id,
})
);
expect(payload.canManageTags).toBe(true);
expect(payload.canShareVideo).toBe(true);
});
it('refuses even the owner once their billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
const video = await createVideo({ projectId: project.id });
await createVersion({ videoParentId: video.id });
signedInAs(expiredOwner);
const response = await callRoute(watchVideo, apiRequest(`/api/watch/${video.id}`), {
videoId: video.id,
});
expect(response.status).toBe(403);
});
it('nests comments with per-viewer capability flags and hides identity columns', async () => {
const scenario = await seedVersion({ visibility: 'PUBLIC' });
const otherAuthor = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: otherAuthor.id });
const mine = await createComment({
versionId: scenario.version.id,
authorId: scenario.owner.id,
content: 'mine',
});
const theirs = await createComment({
versionId: scenario.version.id,
authorId: otherAuthor.id,
content: 'theirs',
});
await createComment({
versionId: scenario.version.id,
authorId: otherAuthor.id,
parentId: theirs.id,
content: 'a reply',
});
signedInAs(scenario.owner);
const payload = await readData<{
versions: Array<{
comments: Array<{
id: string;
canEdit: boolean;
canDelete: boolean;
authorId?: string;
guestIdentityId?: string;
replies: Array<{ id: string; canEdit: boolean; canDelete: boolean }>;
}>;
}>;
}>(
await callRoute(
watchVideo,
apiRequest(`/api/watch/${scenario.video.id}?includeComments=true`),
{ videoId: scenario.video.id }
)
);
const comments = payload.versions[0].comments;
expect(comments).toHaveLength(2);
const own = comments.find((entry) => entry.id === mine.id)!;
const other = comments.find((entry) => entry.id === theirs.id)!;
expect(own.canEdit).toBe(true);
expect(own.canDelete).toBe(true);
// The project owner may delete anyone's comment but not rewrite it.
expect(other.canEdit).toBe(false);
expect(other.canDelete).toBe(true);
expect(other.replies).toHaveLength(1);
expect(other.replies[0].canDelete).toBe(true);
for (const comment of comments) {
expect(comment).not.toHaveProperty('authorId');
expect(comment).not.toHaveProperty('guestIdentityId');
}
});
it('omits comments entirely unless includeComments=true', async () => {
const scenario = await seedVersion();
await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id });
signedInAs(scenario.owner);
const payload = await readData<{ versions: Array<Record<string, unknown>> }>(
await callRoute(watchVideo, apiRequest(`/api/watch/${scenario.video.id}`), {
videoId: scenario.video.id,
})
);
expect(payload.versions[0]).not.toHaveProperty('comments');
expect(payload.versions[0]).toHaveProperty('_count');
});
});
describe('GET /api/watch/[videoId]/progress', () => {
it('returns 401 without a session, even for a PUBLIC video', async () => {
const scenario = await seedVersion({ visibility: 'PUBLIC' });
signedOut();
const response = await callRoute(
getProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(401);
});
it('returns 403 for a signed-in stranger on a PRIVATE video', async () => {
const scenario = await seedVersion({ visibility: 'PRIVATE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('returns zero progress and the version duration when nothing is stored', async () => {
const scenario = await seedVersion({ duration: 300 });
signedInAs(scenario.owner);
const payload = await readData<{
progress: number;
duration: number;
percentage: number;
updatedAt: string | null;
}>(
await callRoute(getProgress, apiRequest(`/api/watch/${scenario.video.id}/progress`), {
videoId: scenario.video.id,
})
);
expect(payload).toEqual({ progress: 0, duration: 300, percentage: 0, updatedAt: null });
});
it('returns 404 when the video has no active version', async () => {
const scenario = await seedVersion();
await db.videoVersion.update({
where: { id: scenario.version.id },
data: { isActive: false },
});
signedInAs(scenario.owner);
const response = await callRoute(
getProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(404);
});
it('reads back only the calling user own progress', async () => {
const scenario = await seedVersion();
const other = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: other.id });
await db.watchProgress.create({
data: {
userId: other.id,
versionId: scenario.version.id,
progress: 90,
duration: 120,
percentage: 75,
},
});
signedInAs(scenario.owner);
const payload = await readData<{ progress: number; percentage: number }>(
await callRoute(getProgress, apiRequest(`/api/watch/${scenario.video.id}/progress`), {
videoId: scenario.video.id,
})
);
expect(payload.progress).toBe(0);
expect(payload.percentage).toBe(0);
});
});
describe('POST /api/watch/[videoId]/progress', () => {
it('returns 401 without a session and stores nothing', async () => {
const scenario = await seedVersion({ visibility: 'PUBLIC' });
signedOut();
const response = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 10, duration: 100 },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(401);
expect(await db.watchProgress.count()).toBe(0);
});
it('returns 403 for a signed-in stranger', async () => {
const scenario = await seedVersion();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 10, duration: 100 },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
expect(await db.watchProgress.count()).toBe(0);
});
it.each([
[{ progress: -1, duration: 100 }, 'a negative progress'],
[{ progress: 86_401, duration: 100 }, 'a progress past 24 hours'],
[{ progress: 'ten', duration: 100 }, 'a non-numeric progress'],
[{ duration: 100 }, 'a missing progress'],
[{ progress: 10, duration: -5 }, 'a negative duration'],
[{ progress: 10, duration: 86_401 }, 'a duration past 24 hours'],
[{ progress: 10, duration: 100, versionId: 5 }, 'a non-string versionId'],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, { body }),
{ videoId: scenario.video.id }
);
expect(response.status, label).toBe(400);
expect(await db.watchProgress.count()).toBe(0);
});
it('upserts progress for the caller with a computed percentage', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const first = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 30, duration: 120 },
}),
{ videoId: scenario.video.id }
);
const second = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 60, duration: 120 },
}),
{ videoId: scenario.video.id }
);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
const rows = await db.watchProgress.findMany();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(scenario.owner.id);
expect(rows[0].versionId).toBe(scenario.version.id);
expect(rows[0].progress).toBe(60);
expect(rows[0].percentage).toBe(50);
});
it('clamps the percentage at 100 when progress exceeds the reported duration', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 500, duration: 100 },
}),
{ videoId: scenario.video.id }
);
expect((await db.watchProgress.findFirstOrThrow()).percentage).toBe(100);
});
it('records zero percent when no duration is supplied', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, { body: { progress: 42 } }),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const stored = await db.watchProgress.findFirstOrThrow();
expect(stored.duration).toBe(0);
expect(stored.percentage).toBe(0);
});
it('returns 404 for a versionId that belongs to a different video', async () => {
const scenario = await seedVersion();
const other = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 10, duration: 100, versionId: other.version.id },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(404);
expect(await db.watchProgress.count()).toBe(0);
});
it('writes to the named version of the same video when versionId is supplied', async () => {
const scenario = await seedVersion();
const older = await createVersion({
videoParentId: scenario.video.id,
versionNumber: 2,
isActive: false,
});
signedInAs(scenario.owner);
const response = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 10, duration: 100, versionId: older.id },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(200);
expect((await db.watchProgress.findFirstOrThrow()).versionId).toBe(older.id);
});
it('ignores a userId supplied in the body and always writes for the session user', async () => {
const scenario = await seedVersion();
const victim = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: victim.id });
signedInAs(scenario.owner);
const response = await callRoute(
saveProgress,
apiRequest(`/api/watch/${scenario.video.id}/progress`, {
body: { progress: 10, duration: 100, userId: victim.id },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const rows = await db.watchProgress.findMany();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(scenario.owner.id);
});
});
describe('POST /api/watch/[videoId]/upload-token', () => {
it('returns 403 without an Origin header', async () => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
issueUploadToken,
apiRequest(`/api/watch/${scenario.video.id}/upload-token`, { body: { intent: 'image' } }),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('returns 403 for a cross-origin request', async () => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
issueUploadToken,
apiRequest(`/api/watch/${scenario.video.id}/upload-token`, {
body: { intent: 'image' },
headers: { origin: 'https://evil.example.com' },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('returns 400 for a signed-in caller, who does not need a guest token', async () => {
const scenario = await seedVersion();
signedInAs(scenario.owner);
const response = await callRoute(
issueUploadToken,
apiRequest(`/api/watch/${scenario.video.id}/upload-token`, {
body: { intent: 'image' },
headers: { origin: ORIGIN },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
});
it.each([['video'], [''], ['IMAGE']])('returns 400 for the intent %s', async (intent) => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
issueUploadToken,
apiRequest(`/api/watch/${scenario.video.id}/upload-token`, {
body: { intent },
headers: { origin: ORIGIN },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
});
it('returns 403 for a guest with no share session', async () => {
const scenario = await seedVersion();
signedOut();
const response = await callRoute(
issueUploadToken,
apiRequest(`/api/watch/${scenario.video.id}/upload-token`, {
body: { intent: 'image' },
headers: { origin: ORIGIN },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('returns 403 for a guest holding only a VIEW share session', async () => {
const scenario = await seedVersion();
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'VIEW',
});
signedOut();
const response = await callRoute(
issueUploadToken,
apiRequest(`/api/watch/${scenario.video.id}/upload-token`, {
body: { intent: 'image' },
headers: { origin: ORIGIN },
cookies: shareCookie(scenario.video.id, link.token),
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
it('returns 403 when the COMMENT link disallows guests', async () => {
const scenario = await seedVersion();
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
allowGuests: false,
});
signedOut();
const response = await callRoute(
issueUploadToken,
apiRequest(`/api/watch/${scenario.video.id}/upload-token`, {
body: { intent: 'image' },
headers: { origin: ORIGIN },
cookies: shareCookie(scenario.video.id, link.token),
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
// The token is scoped to project, video and intent. An image token must not
// be accepted for an audio upload, nor for another video.
it('issues a token scoped to the video, project and intent', async () => {
const scenario = await seedVersion();
const otherVideo = await createVideo({ projectId: scenario.project.id });
const link = await createShareLink({
projectId: scenario.project.id,
videoId: scenario.video.id,
permission: 'COMMENT',
allowGuests: true,
});
signedOut();
const request = apiRequest(`/api/watch/${scenario.video.id}/upload-token`, {
body: { intent: 'image' },
headers: { origin: ORIGIN },
cookies: shareCookie(scenario.video.id, link.token),
});
const response = await callRoute(issueUploadToken, request, { videoId: scenario.video.id });
const payload = await readData<{ token: string; intent: string; expiresInSeconds: number }>(
response
);
expect(response.status).toBe(200);
expect(payload.intent).toBe('image');
expect(payload.expiresInSeconds).toBeGreaterThan(0);
const context = deriveGuestUploadContext(request, link.token)!;
expect(context).toBeTruthy();
expect(
verifyGuestUploadToken(payload.token, {
projectId: scenario.project.id,
videoId: scenario.video.id,
intent: 'image',
context,
})
).toBe(true);
expect(
verifyGuestUploadToken(payload.token, {
projectId: scenario.project.id,
videoId: scenario.video.id,
intent: 'audio',
context,
})
).toBe(false);
expect(
verifyGuestUploadToken(payload.token, {
projectId: scenario.project.id,
videoId: otherVideo.id,
intent: 'image',
context,
})
).toBe(false);
// The context binds the token to the presented share token, so a token
// minted through one share link cannot be replayed through another.
const otherLinkContext = deriveGuestUploadContext(request, 'a-different-share-token')!;
expect(
verifyGuestUploadToken(payload.token, {
projectId: scenario.project.id,
videoId: scenario.video.id,
intent: 'image',
context: otherLinkContext,
})
).toBe(false);
});
});
+675
View File
@@ -0,0 +1,675 @@
import { describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { GET as listWorkspaces, POST as createWorkspaceRoute } from '@/app/api/workspaces/route';
import {
DELETE as deleteWorkspace,
GET as getWorkspace,
PATCH as patchWorkspace,
} from '@/app/api/workspaces/[workspaceId]/route';
import {
GET as listWorkspaceMembers,
POST as inviteWorkspaceMember,
} from '@/app/api/workspaces/[workspaceId]/members/route';
import {
DELETE as removeWorkspaceMember,
PATCH as patchWorkspaceMember,
} from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createUser,
createVideo,
createWorkspace,
seedProject,
} from '../factories';
describe('GET /api/workspaces', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(listWorkspaces, apiRequest('/api/workspaces'));
expect(response.status).toBe(401);
});
it.each([['page=0'], ['page=1001'], ['limit=0'], ['limit=101'], ['page=1000&limit=100']])(
'rejects ?%s with 400',
async (query) => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(listWorkspaces, apiRequest(`/api/workspaces?${query}`));
expect(response.status).toBe(400);
}
);
it('lists owned and joined workspaces with pagination metadata', async () => {
const user = await createUser();
const owned = await createWorkspace({ ownerId: user.id });
const host = await createUser();
const joined = await createWorkspace({ ownerId: host.id });
await addWorkspaceMember({ workspaceId: joined.id, userId: user.id });
await createWorkspace({ ownerId: host.id });
signedInAs(user);
const payload = await readJson<{
data: { workspaces: Array<{ id: string }> };
meta: { total: number; totalPages: number };
}>(await callRoute(listWorkspaces, apiRequest('/api/workspaces')));
expect(payload.data.workspaces.map((entry) => entry.id).sort()).toEqual(
[owned.id, joined.id].sort()
);
expect(payload.meta.total).toBe(2);
expect(payload.meta.totalPages).toBe(1);
});
it('hides a joined workspace whose owner has lost billing access', async () => {
const expiredHost = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredHost.id });
const member = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: member.id });
signedInAs(member);
const payload = await readData<{ workspaces: Array<{ id: string }> }>(
await callRoute(listWorkspaces, apiRequest('/api/workspaces'))
);
expect(payload.workspaces).toEqual([]);
});
it('hides an owned workspace from an owner who has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
await createWorkspace({ ownerId: expiredOwner.id });
signedInAs(expiredOwner);
const payload = await readData<{ workspaces: Array<{ id: string }> }>(
await callRoute(listWorkspaces, apiRequest('/api/workspaces'))
);
expect(payload.workspaces).toEqual([]);
});
});
describe('POST /api/workspaces', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'X' } })
);
expect(response.status).toBe(401);
expect(await db.workspace.count()).toBe(0);
});
it.each([[{}], [{ name: '' }], [{ name: ' ' }], [{ name: 99 }]])(
'rejects %j with 400',
async (body) => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body })
);
expect(response.status).toBe(400);
expect(await db.workspace.count()).toBe(0);
}
);
it('creates the workspace owned by the caller with a derived slug', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', {
body: { name: ' My Studio! ', description: ' a place ', ownerId: 'someone-else' },
})
);
expect(response.status).toBe(201);
const stored = await db.workspace.findFirstOrThrow();
expect(stored.name).toBe('My Studio!');
expect(stored.slug).toBe('my-studio');
expect(stored.description).toBe('a place');
// ownerId in the body is ignored: it decides who pays.
expect(stored.ownerId).toBe(user.id);
});
it('gives same-named workspaces distinct slugs', async () => {
const user = await createUser();
signedInAs(user);
for (let index = 0; index < 3; index += 1) {
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Studio' } })
);
expect(response.status).toBe(201);
}
expect(
(await db.workspace.findMany({ select: { slug: true } })).map((row) => row.slug).sort()
).toEqual(['studio', 'studio-1', 'studio-2']);
});
// getWorkspaceCreationEligibility lets a brand-new, unbilled user create their
// very first workspace so that signup is not a dead end.
it('lets an expired user create their first workspace', async () => {
const expired = await createExpiredUser();
signedInAs(expired);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'First Try' } })
);
expect(response.status).toBe(201);
expect(await db.workspace.count()).toBe(1);
});
it('refuses a second workspace for an expired user', async () => {
const expired = await createExpiredUser();
await createWorkspace({ ownerId: expired.id });
signedInAs(expired);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Second Try' } })
);
expect(response.status).toBe(403);
expect(await db.workspace.count()).toBe(1);
});
it('refuses a first workspace for an expired user who is collaborating elsewhere', async () => {
const host = await seedProject();
const expired = await createExpiredUser();
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: expired.id });
signedInAs(expired);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Freeloader' } })
);
expect(response.status).toBe(403);
expect(await db.workspace.count()).toBe(1);
});
it('refuses a first workspace for an expired user who is a project-only collaborator', async () => {
const host = await seedProject();
const expired = await createExpiredUser();
await addProjectMember({ projectId: host.project.id, userId: expired.id });
signedInAs(expired);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Freeloader' } })
);
expect(response.status).toBe(403);
expect(await db.workspace.count()).toBe(1);
});
it('lets a subscribed user create any number of workspaces', async () => {
const user = await createUser();
await createWorkspace({ ownerId: user.id });
await createWorkspace({ ownerId: user.id });
signedInAs(user);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Third' } })
);
expect(response.status).toBe(201);
expect(await db.workspace.count()).toBe(3);
});
it('lets a self-hosted instance with billing disabled create freely', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const expired = await createExpiredUser();
await createWorkspace({ ownerId: expired.id });
signedInAs(expired);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Self Hosted' } })
);
expect(response.status).toBe(201);
});
});
describe('GET /api/workspaces/[workspaceId]', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
getWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}`),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(401);
});
it('returns 404 for an unknown workspace', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(getWorkspace, apiRequest('/api/workspaces/nope'), {
workspaceId: 'nope',
});
expect(response.status).toBe(404);
});
it('returns 403 for a signed-in stranger', async () => {
const scenario = await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}`),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(403);
});
it.each([['limit=0'], ['limit=101'], ['offset=-1'], ['offset=10001']])(
'rejects ?%s with 400',
async (query) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
getWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}?${query}`),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(400);
}
);
it('serves a COMMENTATOR member the workspace with its projects', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: member.id,
role: 'COMMENTATOR',
});
signedInAs(member);
const payload = await readData<{
id: string;
projects: Array<{ id: string }>;
members: Array<{ userId: string }>;
_count: { projects: number; members: number };
}>(
await callRoute(getWorkspace, apiRequest(`/api/workspaces/${scenario.workspace.id}`), {
workspaceId: scenario.workspace.id,
})
);
expect(payload.id).toBe(scenario.workspace.id);
expect(payload.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
expect(payload._count).toEqual({ projects: 1, members: 1 });
});
});
describe('PATCH /api/workspaces/[workspaceId]', () => {
it('returns 403 for a COMMENTATOR member', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: member.id,
role: 'COMMENTATOR',
});
signedInAs(member);
const response = await callRoute(
patchWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}`, {
method: 'PATCH',
body: { name: 'Renamed' },
}),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(403);
expect(
(await db.workspace.findUniqueOrThrow({ where: { id: scenario.workspace.id } })).name
).toBe(scenario.workspace.name);
});
it.each([
[{ name: '' }],
[{ name: 'x'.repeat(101) }],
[{ description: 'x'.repeat(1001) }],
[{ description: 7 }],
])('rejects %j with 400', async (body) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
patchWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}`, { method: 'PATCH', body }),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(400);
});
it('lets a workspace ADMIN rename it, ignoring an ownerId in the body', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: admin.id,
role: 'ADMIN',
});
signedInAs(admin);
const response = await callRoute(
patchWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}`, {
method: 'PATCH',
body: { name: ' Renamed ', description: null, ownerId: admin.id },
}),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(200);
const stored = await db.workspace.findUniqueOrThrow({
where: { id: scenario.workspace.id },
});
expect(stored.name).toBe('Renamed');
expect(stored.description).toBeNull();
expect(stored.ownerId).toBe(scenario.owner.id);
});
});
describe('DELETE /api/workspaces/[workspaceId]', () => {
it('returns 403 for a workspace ADMIN, who may edit but not destroy', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: admin.id,
role: 'ADMIN',
});
signedInAs(admin);
const response = await callRoute(
deleteWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}`, { method: 'DELETE' }),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(403);
expect(await db.workspace.count()).toBe(1);
});
it('deletes the workspace and cascades to projects and videos for the owner', async () => {
const scenario = await seedProject();
await createVideo({ projectId: scenario.project.id });
signedInAs(scenario.owner);
const response = await callRoute(
deleteWorkspace,
apiRequest(`/api/workspaces/${scenario.workspace.id}`, { method: 'DELETE' }),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(200);
expect(await db.workspace.count()).toBe(0);
expect(await db.project.count()).toBe(0);
expect(await db.video.count()).toBe(0);
});
});
describe('workspace members', () => {
it('returns 403 to a stranger listing the roster', async () => {
const scenario = await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
listWorkspaceMembers,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members`),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(403);
});
it('returns 403 when a COMMENTATOR tries to invite', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: member.id,
role: 'COMMENTATOR',
});
signedInAs(member);
const response = await callRoute(
inviteWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members`, {
body: { email: '[email protected]', role: 'ADMIN' },
}),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(403);
expect(await db.invitation.count()).toBe(0);
});
it('returns 400 when inviting the workspace owner', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
inviteWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members`, {
body: { email: scenario.owner.email! },
}),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(400);
expect(await db.invitation.count()).toBe(0);
});
it('returns 409 when inviting an existing member', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: member.id });
signedInAs(scenario.owner);
const response = await callRoute(
inviteWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members`, {
body: { email: member.email! },
}),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(409);
expect(await db.invitation.count()).toBe(0);
});
it('creates a WORKSPACE-scoped invitation for an ADMIN', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: admin.id,
role: 'ADMIN',
});
signedInAs(admin);
const response = await callRoute(
inviteWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members`, {
body: { email: '[email protected]', role: 'ADMIN' },
}),
{ workspaceId: scenario.workspace.id }
);
expect(response.status).toBe(200);
const invitation = await db.invitation.findFirstOrThrow();
expect(invitation.email).toBe('[email protected]');
expect(invitation.scope).toBe('WORKSPACE');
expect(invitation.role).toBe('ADMIN');
expect(invitation.workspaceId).toBe(scenario.workspace.id);
expect(invitation.projectId).toBeNull();
expect(await db.workspaceMember.count()).toBe(1);
});
it('returns 404 when the membership row belongs to another workspace', async () => {
const mine = await seedProject();
const theirs = await seedProject();
const victim = await createUser();
const foreign = await addWorkspaceMember({
workspaceId: theirs.workspace.id,
userId: victim.id,
role: 'COMMENTATOR',
});
signedInAs(mine.owner);
const response = await callRoute(
patchWorkspaceMember,
apiRequest(`/api/workspaces/${mine.workspace.id}/members/${foreign.id}`, {
method: 'PATCH',
body: { role: 'ADMIN' },
}),
{ workspaceId: mine.workspace.id, memberId: foreign.id }
);
expect(response.status).toBe(404);
expect((await db.workspaceMember.findUniqueOrThrow({ where: { id: foreign.id } })).role).toBe(
'COMMENTATOR'
);
});
// The owner is not a WorkspaceMember row, so there is no id that could remove
// them and no way to leave a workspace unowned.
it('cannot remove the workspace owner', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
removeWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/${scenario.owner.id}`, {
method: 'DELETE',
}),
{ workspaceId: scenario.workspace.id, memberId: scenario.owner.id }
);
expect(response.status).toBe(404);
expect(
(await db.workspace.findUniqueOrThrow({ where: { id: scenario.workspace.id } })).ownerId
).toBe(scenario.owner.id);
});
it('returns 403 when a COMMENTATOR removes somebody else', async () => {
const scenario = await seedProject();
const commentator = await createUser();
const victim = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
const victimMember = await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: victim.id,
});
signedInAs(commentator);
const response = await callRoute(
removeWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/${victimMember.id}`, {
method: 'DELETE',
}),
{ workspaceId: scenario.workspace.id, memberId: victimMember.id }
);
expect(response.status).toBe(403);
expect(await db.workspaceMember.count()).toBe(2);
});
// Removing somebody has to leave the workspace's projects intact: their
// project memberships go, and any project they owned reverts to the workspace
// owner rather than being orphaned.
it('reassigns the projects of a removed member to the workspace owner', async () => {
const scenario = await seedProject();
const leaver = await createUser();
const membership = await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: leaver.id,
role: 'ADMIN',
});
const theirProject = await createProject({
ownerId: leaver.id,
workspaceId: scenario.workspace.id,
});
await addProjectMember({ projectId: scenario.project.id, userId: leaver.id });
signedInAs(scenario.owner);
const response = await callRoute(
removeWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/${membership.id}`, {
method: 'DELETE',
}),
{ workspaceId: scenario.workspace.id, memberId: membership.id }
);
expect(response.status).toBe(200);
expect(await db.workspaceMember.count()).toBe(0);
expect(await db.projectMember.count()).toBe(0);
expect((await db.project.findUniqueOrThrow({ where: { id: theirProject.id } })).ownerId).toBe(
scenario.owner.id
);
});
it('lets a member remove itself', async () => {
const scenario = await seedProject();
const member = await createUser();
const membership = await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: member.id,
role: 'COMMENTATOR',
});
signedInAs(member);
const response = await callRoute(
removeWorkspaceMember,
apiRequest(`/api/workspaces/${scenario.workspace.id}/members/${membership.id}`, {
method: 'DELETE',
}),
{ workspaceId: scenario.workspace.id, memberId: membership.id }
);
expect(response.status).toBe(200);
expect(await db.workspaceMember.count()).toBe(0);
});
});