Files
OpenFrame/tests/unit/lib/route-access.test.ts
T
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine
agents wrote suites in parallel against private databases, then a tenth read all
of it adversarially and five of its findings were fixed.

  unit + component  2076 -> 2079 (+888 over the round)
  api                647 -> 1015
  e2e                 18 -> 29

What was closed:

- lib/route-access.ts, the page-level authorization layer, went from zero tests
  to 48. Every API route was guarded and none of the pages were.
- The five media proxy routes now have a real 2xx beside every 403. The blocker
  was the positive control, solved by stubbing r2Client.send() and leaving
  lib/r2-media-proxy.ts itself real.
- Every remaining server-side lib module: invitations, email verification, the
  upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle,
  notifications and admin stats.
- Six video-page hooks, and the chunking arithmetic extracted out of
  lib/client/r2-video-upload.ts as a pure module.
- Five end-to-end flows: workspace members, bulk operations, the admin area,
  player interaction and failure recovery.

Three things about the harness itself turned out to be wrong:

- Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every
  route reaching finalizeR2VideoUpload silently took the "not a valid video"
  branch and no test noticed.
- The auth matrix asserted only "not 2xx", which two entries satisfied without
  their guard existing. It now requires 401 or 403, which makes both
  load-bearing, and all 60 routes pass the stricter form.
- Both admin API routes had no positive control anywhere: replacing their guard
  with an unconditional refusal left the entire suite green. Found by the
  adversarial review, now covered.

Process:

- bun run test:mutation runs StrykerJS over the authorization and validation
  modules. Diagnostic, not a gate, weekly in CI rather than on a push.
- playwright.config.ts gains an opt-in webkit project for the player spec.
- AGENTS.md now requires a batch of new tests to be reviewed by somebody who
  did not write them.

Only two production files change, both deliberate: lib/auth.ts loses a verbatim
copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the
extracted arithmetic. No behaviour change in either.
2026-07-26 13:25:11 +07:00

717 lines
26 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BillingSubscriptionStatus } from '@prisma/client';
import { buildBillingAccessWhereInput } from '@/lib/billing';
import {
hasAppNavigationAccess,
hasCollaboratorBillingBackedAccess,
requireAuthOrRedirect,
requireBillingAccessOrRedirect,
requireProjectAccessOrRedirect,
requireVideoProjectAccessOrRedirect,
requireWorkspaceAccessOrRedirect,
} from '@/lib/route-access';
// The real redirect() and notFound() abort rendering by throwing. A mock that
// returns normally would let execution fall through into code that can never run
// in production, and every assertion after that point would describe a fiction.
// In particular lib/route-access.ts has branches that call redirectForMissingAuth()
// and then redirectForForbidden() on the following line; only a throwing mock
// shows which of the two a real request would land on.
const nav = vi.hoisted(() => {
class RedirectError extends Error {
constructor(readonly path: string) {
super(`NEXT_REDIRECT ${path}`);
}
}
class NotFoundError extends Error {
constructor() {
super('NEXT_NOT_FOUND');
}
}
return {
RedirectError,
NotFoundError,
redirect: vi.fn((path: string): never => {
throw new RedirectError(path);
}),
notFound: vi.fn((): never => {
throw new NotFoundError();
}),
};
});
vi.mock('next/navigation', () => ({ redirect: nav.redirect, notFound: nav.notFound }));
// The permission formulas themselves live in lib/auth.ts and are covered by
// tests/unit/lib/project-access.test.ts against the real matrix. Here they are
// stubbed so each test can pin one access verdict and assert only on what
// route-access.ts does with it.
const authModule = vi.hoisted(() => ({
auth: vi.fn(),
checkProjectAccess: vi.fn(),
checkWorkspaceAccess: vi.fn(),
}));
vi.mock('@/lib/auth', () => authModule);
const dbMock = vi.hoisted(() => ({
user: { findUnique: vi.fn() },
workspace: { findUnique: vi.fn(), count: vi.fn() },
project: { findUnique: vi.fn(), count: vi.fn() },
video: { findFirst: vi.fn() },
}));
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
// The three redirect targets are written out by hand rather than imported, so
// changing a target in lib/route-access.ts fails here instead of silently
// agreeing with itself. /login and /dashboard match what the pages that do their
// own session check use (app/(dashboard)/dashboard/page.tsx redirects anonymous
// callers to /login); /settings is the page that renders the billing-only view
// when hasBillingAccess is false.
const LOGIN = '/login';
const FORBIDDEN = '/dashboard';
const BILLING = '/settings';
const NOW = new Date('2026-01-15T00:00:00.000Z');
const USER_ID = 'user-signed-in';
const OTHER_USER_ID = 'user-from-session';
const PROJECT_ID = 'project-1';
const WORKSPACE_ID = 'workspace-1';
const VIDEO_ID = 'video-1';
const ACTIVE_BILLING = {
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
trialEndsAt: null,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
};
const LAPSED_BILLING = {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
trialEndsAt: new Date('2025-12-01T00:00:00.000Z'),
stripeCurrentPeriodEnd: new Date('2025-12-08T00:00:00.000Z'),
billingAccessEndedAt: new Date('2025-12-08T00:00:00.000Z'),
};
const PROJECT_ROW = {
id: PROJECT_ID,
ownerId: USER_ID,
workspaceId: WORKSPACE_ID,
visibility: 'PRIVATE',
};
const PUBLIC_PROJECT_ROW = { ...PROJECT_ROW, visibility: 'PUBLIC' };
const WORKSPACE_ROW = { id: WORKSPACE_ID, ownerId: USER_ID };
const VIDEO_ROW = { id: VIDEO_ID, project: PROJECT_ROW };
type ProjectAccessResult = {
isOwner: boolean;
isProjectMember: boolean;
isProjectAdmin: boolean;
isWorkspaceMember: boolean;
isWorkspaceAdmin: boolean;
hasAccess: boolean;
canEdit: boolean;
canDelete: boolean;
ownerBillingActive: boolean;
};
function projectAccess(overrides: Partial<ProjectAccessResult> = {}): ProjectAccessResult {
return {
isOwner: false,
isProjectMember: false,
isProjectAdmin: false,
isWorkspaceMember: false,
isWorkspaceAdmin: false,
hasAccess: false,
canEdit: false,
canDelete: false,
ownerBillingActive: true,
...overrides,
};
}
type WorkspaceAccessResult = {
isOwner: boolean;
isMember: boolean;
isAdmin: boolean;
hasAccess: boolean;
canEdit: boolean;
canDelete: boolean;
ownerBillingActive: boolean;
};
function workspaceAccess(overrides: Partial<WorkspaceAccessResult> = {}): WorkspaceAccessResult {
return {
isOwner: false,
isMember: false,
isAdmin: false,
hasAccess: false,
canEdit: false,
canDelete: false,
ownerBillingActive: true,
...overrides,
};
}
/**
* Asserts the call aborted through redirect() with exactly one target. The
* "exactly one" half matters: several branches queue a second redirect on the
* line below, and only the first one can ever take effect at runtime.
*/
async function expectRedirect(call: Promise<unknown>, path: string) {
await expect(call).rejects.toBeInstanceOf(nav.RedirectError);
expect(nav.redirect).toHaveBeenCalledTimes(1);
expect(nav.redirect).toHaveBeenCalledWith(path);
expect(nav.notFound).not.toHaveBeenCalled();
}
async function expectNotFound(call: Promise<unknown>) {
await expect(call).rejects.toBeInstanceOf(nav.NotFoundError);
expect(nav.notFound).toHaveBeenCalledTimes(1);
expect(nav.redirect).not.toHaveBeenCalled();
}
beforeEach(() => {
vi.clearAllMocks();
// hasBillingAccess() short-circuits to true when Stripe is off, which would
// make every lapsed-billing fixture read as paid.
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.useFakeTimers();
vi.setSystemTime(NOW);
authModule.auth.mockResolvedValue(null);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
describe('requireAuthOrRedirect', () => {
it('sends an anonymous caller to the login page', async () => {
authModule.auth.mockResolvedValue(null);
await expectRedirect(requireAuthOrRedirect(), LOGIN);
});
it('sends a session with no user id to the login page', async () => {
// next-auth can hand back a session object whose user was never resolved.
authModule.auth.mockResolvedValue({ user: { email: '[email protected]' } });
await expectRedirect(requireAuthOrRedirect(), LOGIN);
});
it('returns the session untouched for a signed-in caller', async () => {
const session = { user: { id: USER_ID, email: '[email protected]' } };
authModule.auth.mockResolvedValue(session);
await expect(requireAuthOrRedirect()).resolves.toEqual(session);
expect(nav.redirect).not.toHaveBeenCalled();
});
});
describe('requireBillingAccessOrRedirect', () => {
it('sends an anonymous caller to the login page without reading the user row', async () => {
authModule.auth.mockResolvedValue(null);
await expectRedirect(requireBillingAccessOrRedirect(), LOGIN);
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
});
it('sends a caller whose user row is gone to the billing settings page', async () => {
dbMock.user.findUnique.mockResolvedValue(null);
await expectRedirect(requireBillingAccessOrRedirect({ userId: USER_ID }), BILLING);
});
it('sends a caller whose billing has lapsed to the billing settings page', async () => {
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
await expectRedirect(requireBillingAccessOrRedirect({ userId: USER_ID }), BILLING);
});
it('returns the billing columns for a caller who is still paying', async () => {
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
await expect(requireBillingAccessOrRedirect({ userId: USER_ID })).resolves.toEqual(
ACTIVE_BILLING
);
expect(nav.redirect).not.toHaveBeenCalled();
});
it('keeps access for a caller inside an unexpired trial', async () => {
dbMock.user.findUnique.mockResolvedValue({
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: new Date('2026-01-16T00:00:00.000Z'),
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
});
await expect(requireBillingAccessOrRedirect({ userId: USER_ID })).resolves.toBeTruthy();
expect(nav.redirect).not.toHaveBeenCalled();
});
it('trusts the caller-supplied user id over the session', async () => {
// Pages that already resolved a session pass the id down to save a round
// trip; the passed id has to win, or one user is billed against another.
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
await requireBillingAccessOrRedirect({ userId: USER_ID });
expect(dbMock.user.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: USER_ID } })
);
expect(authModule.auth).not.toHaveBeenCalled();
});
});
describe('hasCollaboratorBillingBackedAccess', () => {
beforeEach(() => {
dbMock.workspace.count.mockResolvedValue(0);
dbMock.project.count.mockResolvedValue(0);
});
it('is true when the caller belongs to a workspace whose owner is paying', async () => {
dbMock.workspace.count.mockResolvedValue(1);
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(true);
});
it('is true when the caller belongs to a project whose workspace owner is paying', async () => {
dbMock.project.count.mockResolvedValue(1);
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(true);
});
it('is false when the caller collaborates nowhere', async () => {
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(false);
});
it('counts only workspaces and projects whose owner is inside the billing window', async () => {
await hasCollaboratorBillingBackedAccess(USER_ID);
// buildBillingAccessWhereInput comes from lib/billing, a separately tested
// module, so this pins the filter without reading it out of route-access.
const billingFilter = buildBillingAccessWhereInput(NOW);
expect(dbMock.workspace.count.mock.calls[0][0].where.owner).toEqual(billingFilter);
expect(dbMock.project.count.mock.calls[0][0].where.workspace.owner).toEqual(billingFilter);
});
it('counts a workspace the caller owns and one they were only invited to', async () => {
// The membership arm is the whole point of the workspace half: a collaborator
// who owns no workspace of their own would lose dashboard navigation without
// it, and the project count only papers over that while they happen to sit on
// at least one project row.
await hasCollaboratorBillingBackedAccess(USER_ID);
expect(dbMock.workspace.count.mock.calls[0][0].where.OR).toEqual([
{ ownerId: USER_ID },
{ members: { some: { userId: USER_ID } } },
]);
});
it('counts a project reached only through workspace membership', async () => {
// A workspace COMMENTATOR is on no project row, so dropping this arm would
// strip navigation from every workspace-level collaborator.
await hasCollaboratorBillingBackedAccess(USER_ID);
expect(dbMock.project.count.mock.calls[0][0].where.OR).toEqual([
{ ownerId: USER_ID },
{ members: { some: { userId: USER_ID } } },
{ workspace: { members: { some: { userId: USER_ID } } } },
]);
});
});
describe('hasAppNavigationAccess', () => {
beforeEach(() => {
dbMock.workspace.count.mockResolvedValue(0);
dbMock.project.count.mockResolvedValue(0);
});
it('is true for a paying user without counting collaborations', async () => {
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
expect(dbMock.workspace.count).not.toHaveBeenCalled();
expect(dbMock.project.count).not.toHaveBeenCalled();
});
it('is true for a lapsed user who still collaborates on a paid workspace', async () => {
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
dbMock.workspace.count.mockResolvedValue(1);
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
});
it('is true when the user row is missing but a collaboration exists', async () => {
dbMock.user.findUnique.mockResolvedValue(null);
dbMock.project.count.mockResolvedValue(1);
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
});
it('is false for a lapsed user with nothing left to collaborate on', async () => {
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(false);
});
});
describe('requireWorkspaceAccessOrRedirect', () => {
it('sends an anonymous caller to the login page before the workspace is read', async () => {
authModule.auth.mockResolvedValue(null);
await expectRedirect(requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID }), LOGIN);
expect(dbMock.workspace.findUnique).not.toHaveBeenCalled();
expect(authModule.checkWorkspaceAccess).not.toHaveBeenCalled();
});
it('renders a 404 for a signed-in caller when the workspace does not exist', async () => {
dbMock.workspace.findUnique.mockResolvedValue(null);
await expectNotFound(
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: USER_ID })
);
expect(authModule.checkWorkspaceAccess).not.toHaveBeenCalled();
});
it('sends a signed-in stranger to the dashboard', async () => {
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(workspaceAccess({ hasAccess: false }));
await expectRedirect(
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID }),
FORBIDDEN
);
});
it('sends the owner to the billing settings page when their billing has lapsed', async () => {
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(
workspaceAccess({ isOwner: true, hasAccess: false, ownerBillingActive: false })
);
await expectRedirect(
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: USER_ID }),
BILLING
);
});
it('sends a member who cannot edit to the dashboard when the page needs manage rights', async () => {
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(
workspaceAccess({ isMember: true, hasAccess: true, canEdit: false })
);
await expectRedirect(
requireWorkspaceAccessOrRedirect({
workspaceId: WORKSPACE_ID,
userId: OTHER_USER_ID,
intent: 'manage',
}),
FORBIDDEN
);
});
it('lets a member through on the default view intent even though they cannot edit', async () => {
const access = workspaceAccess({ isMember: true, hasAccess: true, canEdit: false });
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(access);
await expect(
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID })
).resolves.toEqual({ workspace: WORKSPACE_ROW, access });
expect(nav.redirect).not.toHaveBeenCalled();
expect(nav.notFound).not.toHaveBeenCalled();
});
it('lets an admin through on the manage intent', async () => {
const access = workspaceAccess({
isMember: true,
isAdmin: true,
hasAccess: true,
canEdit: true,
});
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(access);
await expect(
requireWorkspaceAccessOrRedirect({
workspaceId: WORKSPACE_ID,
userId: OTHER_USER_ID,
intent: 'manage',
})
).resolves.toEqual({ workspace: WORKSPACE_ROW, access });
});
it('falls back to the session user when no id is passed', async () => {
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(
workspaceAccess({ isOwner: true, hasAccess: true, canEdit: true })
);
await requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID });
expect(authModule.checkWorkspaceAccess).toHaveBeenCalledWith(WORKSPACE_ROW, OTHER_USER_ID);
});
});
describe('requireProjectAccessOrRedirect', () => {
it('sends an anonymous caller to the login page before the project is read', async () => {
authModule.auth.mockResolvedValue(null);
await expectRedirect(requireProjectAccessOrRedirect({ projectId: PROJECT_ID }), LOGIN);
expect(dbMock.project.findUnique).not.toHaveBeenCalled();
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
});
it('sends an anonymous caller to the login page on a public route asking for manage rights', async () => {
// The guest policy runs before any permission check: a guest can only ever
// read, so a manage page is a login redirect regardless of the project.
dbMock.project.findUnique.mockResolvedValue(PUBLIC_PROJECT_ROW);
await expectRedirect(
requireProjectAccessOrRedirect({
projectId: PROJECT_ID,
intent: 'manage',
allowPublicView: true,
}),
LOGIN
);
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
});
it('sends an anonymous caller to the login page rather than a 404 for a missing project', async () => {
// A guest must not be able to tell a project that does not exist apart from
// one they cannot see; both answers have to look the same.
dbMock.project.findUnique.mockResolvedValue(null);
await expectRedirect(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true }),
LOGIN
);
});
it('sends an anonymous caller to the login page, not the dashboard, when a public route holds a private project', async () => {
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
await expectRedirect(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true }),
LOGIN
);
});
it('renders a 404 for a signed-in caller when the project does not exist', async () => {
dbMock.project.findUnique.mockResolvedValue(null);
await expectNotFound(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: USER_ID })
);
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
});
it('sends a signed-in stranger to the dashboard', async () => {
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
await expectRedirect(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: OTHER_USER_ID }),
FORBIDDEN
);
});
it('sends the owner to the dashboard when the workspace owner billing has lapsed', async () => {
// Unlike the workspace helper this path has no /settings branch: a lapsed
// owner lands on /dashboard, which runs its own billing gate and forwards
// them to /settings from there.
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(
projectAccess({ isOwner: true, hasAccess: false, ownerBillingActive: false })
);
await expectRedirect(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: USER_ID }),
FORBIDDEN
);
});
it('sends a read-only member to the dashboard when the page needs manage rights', async () => {
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(
projectAccess({ isProjectMember: true, hasAccess: true, canEdit: false })
);
await expectRedirect(
requireProjectAccessOrRedirect({
projectId: PROJECT_ID,
userId: OTHER_USER_ID,
intent: 'manage',
}),
FORBIDDEN
);
});
it('returns the project row and the access verdict for a permitted viewer', async () => {
const access = projectAccess({ isProjectMember: true, hasAccess: true });
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(access);
await expect(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: OTHER_USER_ID })
).resolves.toEqual({ project: PROJECT_ROW, access });
expect(nav.redirect).not.toHaveBeenCalled();
expect(nav.notFound).not.toHaveBeenCalled();
});
it('lets an anonymous viewer read a public project when the route opts in', async () => {
const access = projectAccess({ hasAccess: true });
dbMock.project.findUnique.mockResolvedValue(PUBLIC_PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(access);
await expect(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true })
).resolves.toEqual({ project: PUBLIC_PROJECT_ROW, access });
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PUBLIC_PROJECT_ROW, undefined, {
intent: 'view',
});
});
it('passes the manage intent down to the permission check', async () => {
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(
projectAccess({ isOwner: true, hasAccess: true, canEdit: true })
);
await requireProjectAccessOrRedirect({
projectId: PROJECT_ID,
userId: USER_ID,
intent: 'manage',
});
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, USER_ID, {
intent: 'manage',
});
});
it('falls back to the session user when no id is passed', async () => {
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
authModule.checkProjectAccess.mockResolvedValue(
projectAccess({ isProjectMember: true, hasAccess: true })
);
await requireProjectAccessOrRedirect({ projectId: PROJECT_ID });
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
intent: 'view',
});
});
});
describe('requireVideoProjectAccessOrRedirect', () => {
const args = { projectId: PROJECT_ID, videoId: VIDEO_ID };
it('sends an anonymous caller to the login page before the video is read', async () => {
authModule.auth.mockResolvedValue(null);
await expectRedirect(requireVideoProjectAccessOrRedirect(args), LOGIN);
expect(dbMock.video.findFirst).not.toHaveBeenCalled();
});
it('looks the video up inside the project from the URL', async () => {
// Without the projectId in the where clause, any video id would resolve
// through any project the caller happens to be allowed to see.
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
authModule.checkProjectAccess.mockResolvedValue(
projectAccess({ isOwner: true, hasAccess: true })
);
await requireVideoProjectAccessOrRedirect({ ...args, userId: USER_ID });
expect(dbMock.video.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: VIDEO_ID, projectId: PROJECT_ID } })
);
});
it('renders a 404 for a signed-in caller when the video is not in that project', async () => {
dbMock.video.findFirst.mockResolvedValue(null);
await expectNotFound(requireVideoProjectAccessOrRedirect({ ...args, userId: USER_ID }));
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
});
it('sends an anonymous caller to the login page rather than a 404 for a missing video', async () => {
dbMock.video.findFirst.mockResolvedValue(null);
await expectRedirect(
requireVideoProjectAccessOrRedirect({ ...args, allowPublicView: true }),
LOGIN
);
});
it('sends an anonymous caller to the login page on a public route asking for manage rights', async () => {
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
await expectRedirect(
requireVideoProjectAccessOrRedirect({ ...args, intent: 'manage', allowPublicView: true }),
LOGIN
);
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
});
it('sends a signed-in stranger to the dashboard', async () => {
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
await expectRedirect(
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID }),
FORBIDDEN
);
});
it('sends a read-only member to the dashboard when the page needs manage rights', async () => {
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
authModule.checkProjectAccess.mockResolvedValue(
projectAccess({ isProjectMember: true, hasAccess: true, canEdit: false })
);
await expectRedirect(
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID, intent: 'manage' }),
FORBIDDEN
);
});
it('authorizes against the parent project and returns it alongside the video', async () => {
const access = projectAccess({ isProjectMember: true, hasAccess: true });
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
authModule.checkProjectAccess.mockResolvedValue(access);
await expect(
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID })
).resolves.toEqual({ video: VIDEO_ROW, project: PROJECT_ROW, access });
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
intent: 'view',
});
});
it('lets an anonymous viewer watch a video in a public project when the route opts in', async () => {
const publicVideo = { id: VIDEO_ID, project: PUBLIC_PROJECT_ROW };
const access = projectAccess({ hasAccess: true });
dbMock.video.findFirst.mockResolvedValue(publicVideo);
authModule.checkProjectAccess.mockResolvedValue(access);
await expect(
requireVideoProjectAccessOrRedirect({ ...args, allowPublicView: true })
).resolves.toEqual({ video: publicVideo, project: PUBLIC_PROJECT_ROW, access });
expect(nav.redirect).not.toHaveBeenCalled();
});
});