Files
OpenFrame/tests/unit/lib/project-access.test.ts
yusufipk 1d099c68f2 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.
2026-07-26 11:17:26 +07:00

593 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BillingSubscriptionStatus, ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { computeProjectAccess, type EnrichedProjectForAccess } from '@/lib/auth';
// `@/lib/auth` reaches `@/lib/db`, which opens a pg Pool and registers process
// signal handlers on import. computeProjectAccess itself touches no database.
vi.mock('@/lib/db', () => ({ db: {}, default: {}, disconnectDb: vi.fn() }));
const PROJECT_OWNER = 'user-project-owner';
const WORKSPACE_OWNER = 'user-workspace-owner';
const OUTSIDER = 'user-outsider';
const PROJECT_COMMENTATOR = 'user-project-commentator';
const PROJECT_ADMIN = 'user-project-admin';
const WORKSPACE_COMMENTATOR = 'user-workspace-commentator';
const WORKSPACE_ADMIN = 'user-workspace-admin';
type OwnerBilling = NonNullable<EnrichedProjectForAccess['workspace']['owner']>;
// computeProjectAccess calls hasBillingAccess() without an injected `now`, so the
// fixtures use dates far enough from any real clock that the result cannot drift.
const ACTIVE_BILLING: OwnerBilling = {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: new Date('2099-01-01T00:00:00Z'),
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
};
const EXPIRED_BILLING: OwnerBilling = {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
trialEndsAt: new Date('2020-01-01T00:00:00Z'),
stripeCurrentPeriodEnd: new Date('2020-01-08T00:00:00Z'),
billingAccessEndedAt: new Date('2020-01-08T00:00:00Z'),
};
type Actor =
| 'anonymous'
| 'signed-in outsider'
| 'project commentator'
| 'project admin'
| 'workspace commentator'
| 'workspace admin'
| 'workspace owner'
| 'project owner';
function userIdFor(actor: Actor): string | undefined {
switch (actor) {
case 'anonymous':
return undefined;
case 'signed-in outsider':
return OUTSIDER;
case 'project commentator':
return PROJECT_COMMENTATOR;
case 'project admin':
return PROJECT_ADMIN;
case 'workspace commentator':
return WORKSPACE_COMMENTATOR;
case 'workspace admin':
return WORKSPACE_ADMIN;
case 'workspace owner':
return WORKSPACE_OWNER;
case 'project owner':
return PROJECT_OWNER;
}
}
function projectMembersFor(actor: Actor): Array<{ role: ProjectMemberRole }> {
if (actor === 'project commentator') return [{ role: ProjectMemberRole.COMMENTATOR }];
if (actor === 'project admin') return [{ role: ProjectMemberRole.ADMIN }];
return [];
}
function workspaceMembersFor(actor: Actor): Array<{ role: WorkspaceMemberRole }> {
if (actor === 'workspace commentator') return [{ role: WorkspaceMemberRole.COMMENTATOR }];
if (actor === 'workspace admin') return [{ role: WorkspaceMemberRole.ADMIN }];
return [];
}
function buildProject(options: {
actor: Actor;
visibility: 'PRIVATE' | 'PUBLIC';
owner: OwnerBilling | null;
}): EnrichedProjectForAccess {
return {
id: 'project-1',
ownerId: PROJECT_OWNER,
workspaceId: 'workspace-1',
visibility: options.visibility,
workspace: {
id: 'workspace-1',
ownerId: WORKSPACE_OWNER,
owner: options.owner,
members: workspaceMembersFor(options.actor),
},
members: projectMembersFor(options.actor),
};
}
interface MatrixCase {
actor: Actor;
visibility: 'PRIVATE' | 'PUBLIC';
billing: 'active' | 'expired';
hasAccess: boolean;
canEdit: boolean;
canDelete: boolean;
isWorkspaceAdmin: boolean;
}
// Expected values are written out by hand, not derived from the production
// formula, so a change in the formula shows up as a failure here.
const matrix: MatrixCase[] = [
// Anonymous: only a public project on a paid workspace is readable.
{
actor: 'anonymous',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'anonymous',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'anonymous',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'anonymous',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
// Signed-in but unrelated user: identical to anonymous.
{
actor: 'signed-in outsider',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'signed-in outsider',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'signed-in outsider',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'signed-in outsider',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
// Project COMMENTATOR: reads a private project, never edits.
{
actor: 'project commentator',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: true,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'project commentator',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'project commentator',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'project commentator',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
// Project ADMIN: edits, but deleting the project stays with the owners.
{
actor: 'project admin',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'project admin',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'project admin',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'project admin',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
// Workspace COMMENTATOR: reads every project in the workspace, edits none.
{
actor: 'workspace commentator',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: true,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'workspace commentator',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'workspace commentator',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'workspace commentator',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
// Workspace ADMIN: edits any project including public ones (the regression
// fixed by fix/public-project-hides-workspace-admin-actions), but cannot delete.
{
actor: 'workspace admin',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: false,
isWorkspaceAdmin: true,
},
{
actor: 'workspace admin',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: false,
isWorkspaceAdmin: true,
},
{
actor: 'workspace admin',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: true,
},
{
actor: 'workspace admin',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: true,
},
// Workspace OWNER: full control while billing holds.
{
actor: 'workspace owner',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: true,
isWorkspaceAdmin: true,
},
{
actor: 'workspace owner',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: true,
isWorkspaceAdmin: true,
},
{
actor: 'workspace owner',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: true,
},
{
actor: 'workspace owner',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: true,
},
// Project owner who is not the workspace owner: full control over the project.
{
actor: 'project owner',
visibility: 'PRIVATE',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: true,
isWorkspaceAdmin: false,
},
{
actor: 'project owner',
visibility: 'PUBLIC',
billing: 'active',
hasAccess: true,
canEdit: true,
canDelete: true,
isWorkspaceAdmin: false,
},
{
actor: 'project owner',
visibility: 'PRIVATE',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
{
actor: 'project owner',
visibility: 'PUBLIC',
billing: 'expired',
hasAccess: false,
canEdit: false,
canDelete: false,
isWorkspaceAdmin: false,
},
];
describe('computeProjectAccess', () => {
beforeEach(() => {
// hasBillingAccess() short-circuits to true when Stripe is disabled, so pin
// the flag on for the whole matrix.
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it.each(matrix)(
'grants { access: $hasAccess, edit: $canEdit, delete: $canDelete } to a $actor on a $visibility project with $billing billing',
({ actor, visibility, billing, hasAccess, canEdit, canDelete, isWorkspaceAdmin }) => {
const project = buildProject({
actor,
visibility,
owner: billing === 'active' ? ACTIVE_BILLING : EXPIRED_BILLING,
});
const access = computeProjectAccess(project, userIdFor(actor));
expect(access.hasAccess).toBe(hasAccess);
expect(access.canEdit).toBe(canEdit);
expect(access.canDelete).toBe(canDelete);
expect(access.isWorkspaceAdmin).toBe(isWorkspaceAdmin);
expect(access.ownerBillingActive).toBe(billing === 'active');
}
);
it('reports every role flag for a project admin who is also a workspace commentator', () => {
const project: EnrichedProjectForAccess = {
id: 'project-1',
ownerId: PROJECT_OWNER,
workspaceId: 'workspace-1',
visibility: 'PRIVATE',
workspace: {
id: 'workspace-1',
ownerId: WORKSPACE_OWNER,
owner: ACTIVE_BILLING,
members: [{ role: WorkspaceMemberRole.COMMENTATOR }],
},
members: [{ role: ProjectMemberRole.ADMIN }],
};
expect(computeProjectAccess(project, PROJECT_ADMIN)).toEqual({
isOwner: false,
isProjectMember: true,
isProjectAdmin: true,
isWorkspaceMember: true,
isWorkspaceAdmin: false,
hasAccess: true,
canEdit: true,
canDelete: false,
ownerBillingActive: true,
});
});
it('denies everything when the workspace owner row is missing', () => {
const project = buildProject({ actor: 'project owner', visibility: 'PUBLIC', owner: null });
const access = computeProjectAccess(project, PROJECT_OWNER);
expect(access.ownerBillingActive).toBe(false);
expect(access.hasAccess).toBe(false);
expect(access.canEdit).toBe(false);
expect(access.canDelete).toBe(false);
// The identity flags still resolve; only the billing gate closed.
expect(access.isOwner).toBe(true);
});
it('denies canEdit when the workspace owner trial has expired', () => {
const project = buildProject({
actor: 'workspace admin',
visibility: 'PRIVATE',
owner: {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: new Date('2020-01-01T00:00:00Z'),
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
});
const access = computeProjectAccess(project, WORKSPACE_ADMIN);
expect(access.isWorkspaceAdmin).toBe(true);
expect(access.canEdit).toBe(false);
expect(access.hasAccess).toBe(false);
});
it('keeps access when the workspace owner subscription is ACTIVE but every date is in the past', () => {
const project = buildProject({
actor: 'workspace commentator',
visibility: 'PRIVATE',
owner: {
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
trialEndsAt: new Date('2020-01-01T00:00:00Z'),
stripeCurrentPeriodEnd: new Date('2020-01-08T00:00:00Z'),
billingAccessEndedAt: null,
},
});
const access = computeProjectAccess(project, WORKSPACE_COMMENTATOR);
expect(access.ownerBillingActive).toBe(true);
expect(access.hasAccess).toBe(true);
});
it('treats billing as active for every workspace when Stripe is disabled', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const project = buildProject({
actor: 'signed-in outsider',
visibility: 'PUBLIC',
owner: EXPIRED_BILLING,
});
const access = computeProjectAccess(project, OUTSIDER);
expect(access.ownerBillingActive).toBe(true);
expect(access.hasAccess).toBe(true);
expect(access.canEdit).toBe(false);
});
it('reads only the first project membership row', () => {
const project: EnrichedProjectForAccess = {
id: 'project-1',
ownerId: PROJECT_OWNER,
workspaceId: 'workspace-1',
visibility: 'PRIVATE',
workspace: {
id: 'workspace-1',
ownerId: WORKSPACE_OWNER,
owner: ACTIVE_BILLING,
members: [],
},
members: [{ role: ProjectMemberRole.COMMENTATOR }, { role: ProjectMemberRole.ADMIN }],
};
const access = computeProjectAccess(project, PROJECT_COMMENTATOR);
expect(access.isProjectAdmin).toBe(false);
expect(access.canEdit).toBe(false);
});
it('prefers the OWNER role over a stale workspace membership row for the same user', () => {
const project: EnrichedProjectForAccess = {
id: 'project-1',
ownerId: PROJECT_OWNER,
workspaceId: 'workspace-1',
visibility: 'PRIVATE',
workspace: {
id: 'workspace-1',
ownerId: WORKSPACE_OWNER,
owner: ACTIVE_BILLING,
members: [{ role: WorkspaceMemberRole.COMMENTATOR }],
},
members: [],
};
const access = computeProjectAccess(project, WORKSPACE_OWNER);
expect(access.isWorkspaceAdmin).toBe(true);
expect(access.canDelete).toBe(true);
});
it('treats an unknown visibility string as private', () => {
const project = buildProject({
actor: 'anonymous',
visibility: 'PUBLIC',
owner: ACTIVE_BILLING,
});
const restricted = { ...project, visibility: 'UNLISTED' };
expect(computeProjectAccess(restricted, undefined).hasAccess).toBe(false);
});
});