mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
717 lines
26 KiB
TypeScript
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();
|
|
});
|
|
});
|