mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ErrorCode,
|
||||
HttpStatus,
|
||||
apiErrors,
|
||||
errorResponse,
|
||||
successResponse,
|
||||
withCacheControl,
|
||||
} from '@/lib/api-response';
|
||||
|
||||
describe('errorResponse', () => {
|
||||
it('returns the message and status with no code when none is given', async () => {
|
||||
const response = errorResponse('Something broke', 500);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.json()).resolves.toEqual({ error: 'Something broke' });
|
||||
});
|
||||
|
||||
it('includes the machine-readable code when given', async () => {
|
||||
const response = errorResponse('Nope', 403, ErrorCode.FORBIDDEN);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({ error: 'Nope', code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('keeps only the field entries that are arrays of strings', async () => {
|
||||
const response = errorResponse('Invalid input', 422, ErrorCode.VALIDATION_ERROR, {
|
||||
email: ['Invalid email format'],
|
||||
password: ['Too short', 'No digit'],
|
||||
leak: 'not-an-array' as unknown as string[],
|
||||
nested: [{ secret: 'value' }] as unknown as string[],
|
||||
mixed: ['ok', 42 as unknown as string],
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'Invalid input',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: {
|
||||
email: ['Invalid email format'],
|
||||
password: ['Too short', 'No digit'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the details key entirely when every entry was rejected', async () => {
|
||||
const response = errorResponse('Invalid input', 422, ErrorCode.VALIDATION_ERROR, {
|
||||
leak: { internal: true } as unknown as string[],
|
||||
});
|
||||
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty('details');
|
||||
});
|
||||
|
||||
it('accepts an empty string array as a valid field entry', async () => {
|
||||
const response = errorResponse('Invalid input', 422, ErrorCode.VALIDATION_ERROR, {
|
||||
email: [],
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({ details: { email: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiErrors', () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
response: ReturnType<typeof errorResponse>;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}> = [
|
||||
{
|
||||
name: 'unauthorized',
|
||||
response: apiErrors.unauthorized(),
|
||||
status: 401,
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
{
|
||||
name: 'forbidden',
|
||||
response: apiErrors.forbidden(),
|
||||
status: 403,
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Forbidden',
|
||||
},
|
||||
{
|
||||
name: 'notFound',
|
||||
response: apiErrors.notFound(),
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Resource not found',
|
||||
},
|
||||
{
|
||||
name: 'badRequest',
|
||||
response: apiErrors.badRequest(),
|
||||
status: 400,
|
||||
code: 'INVALID_INPUT',
|
||||
message: 'Bad request',
|
||||
},
|
||||
{
|
||||
name: 'validationError',
|
||||
response: apiErrors.validationError('Invalid input'),
|
||||
status: 422,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input',
|
||||
},
|
||||
{
|
||||
name: 'conflict',
|
||||
response: apiErrors.conflict('Email already registered'),
|
||||
status: 409,
|
||||
code: 'ALREADY_EXISTS',
|
||||
message: 'Email already registered',
|
||||
},
|
||||
{
|
||||
name: 'rateLimited',
|
||||
response: apiErrors.rateLimited(),
|
||||
status: 429,
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
{
|
||||
name: 'internalError',
|
||||
response: apiErrors.internalError(),
|
||||
status: 500,
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Internal server error',
|
||||
},
|
||||
{
|
||||
name: 'storageExceeded',
|
||||
response: apiErrors.storageExceeded(),
|
||||
status: 507,
|
||||
code: 'STORAGE_LIMIT_EXCEEDED',
|
||||
message: 'Storage limit exceeded. Please delete some files to free up space.',
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
'$name responds $status with code $code',
|
||||
async ({ response, status, code, message }) => {
|
||||
expect(response.status).toBe(status);
|
||||
await expect(response.json()).resolves.toEqual({ error: message, code });
|
||||
}
|
||||
);
|
||||
|
||||
it('interpolates the resource name into the notFound message', async () => {
|
||||
await expect(apiErrors.notFound('Project').json()).resolves.toEqual({
|
||||
error: 'Project not found',
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the caller override the default message', async () => {
|
||||
await expect(apiErrors.forbidden('You are not a workspace admin').json()).resolves.toEqual({
|
||||
error: 'You are not a workspace admin',
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes field details through validationError', async () => {
|
||||
const response = apiErrors.validationError('Invalid input', { title: ['Required'] });
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({ details: { title: ['Required'] } });
|
||||
});
|
||||
|
||||
it('uses distinct status codes for every helper', () => {
|
||||
const statuses = cases.map((entry) => entry.status);
|
||||
expect(new Set(statuses).size).toBe(statuses.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successResponse', () => {
|
||||
it('wraps the payload in a data envelope and defaults to 200', async () => {
|
||||
const response = successResponse({ projects: [] });
|
||||
|
||||
expect(response.status).toBe(HttpStatus.OK);
|
||||
await expect(response.json()).resolves.toEqual({ data: { projects: [] } });
|
||||
});
|
||||
|
||||
it('honours an explicit status such as 201', () => {
|
||||
expect(successResponse({ id: 'p1' }, HttpStatus.CREATED).status).toBe(201);
|
||||
});
|
||||
|
||||
it('sets a json content type', () => {
|
||||
expect(successResponse({ ok: true }).headers.get('content-type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('omits meta when none is supplied', async () => {
|
||||
const body = (await successResponse({ ok: true }).json()) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty('meta');
|
||||
});
|
||||
|
||||
it('serialises pagination meta alongside the data', async () => {
|
||||
const response = successResponse({ projects: [] }, 200, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 100,
|
||||
totalPages: 10,
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: { projects: [] },
|
||||
meta: { page: 1, limit: 10, total: 100, totalPages: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a BigInt as a string instead of throwing', async () => {
|
||||
const response = successResponse({ sizeBytes: BigInt('9007199254740993') });
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: { sizeBytes: '9007199254740993' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders BigInt values nested in arrays and objects', async () => {
|
||||
const response = successResponse({
|
||||
versions: [{ sizeBytes: BigInt(0) }, { sizeBytes: BigInt(-5) }],
|
||||
quota: { used: BigInt(1024), limit: BigInt(5) * BigInt(1024) },
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: {
|
||||
versions: [{ sizeBytes: '0' }, { sizeBytes: '-5' }],
|
||||
quota: { used: '1024', limit: '5120' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('serialises a Date the same way JSON.stringify would', async () => {
|
||||
const response = successResponse({ createdAt: new Date('2026-01-15T00:00:00.000Z') });
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: { createdAt: '2026-01-15T00:00:00.000Z' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withCacheControl', () => {
|
||||
it('sets the Cache-Control header and returns the same response instance', () => {
|
||||
const response = successResponse({ ok: true });
|
||||
|
||||
const returned = withCacheControl(response, 'public, max-age=60');
|
||||
|
||||
expect(returned).toBe(response);
|
||||
expect(response.headers.get('Cache-Control')).toBe('public, max-age=60');
|
||||
});
|
||||
|
||||
it('overwrites a previously set Cache-Control value', () => {
|
||||
const response = new Response(null, { headers: { 'Cache-Control': 'no-store' } });
|
||||
|
||||
withCacheControl(response, 'public, max-age=300');
|
||||
|
||||
expect(response.headers.get('Cache-Control')).toBe('public, max-age=300');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getApprovalCandidatesForProject } from '@/lib/approval-workflow';
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
|
||||
|
||||
interface Candidate {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
function user(id: string, name: string | null, email: string | null = `${id}@example.com`) {
|
||||
return { id, name, email, image: null };
|
||||
}
|
||||
|
||||
function mockProject(options: {
|
||||
owner?: Candidate | null;
|
||||
members?: Array<{ user: Candidate | null }>;
|
||||
workspaceOwner?: Candidate | null;
|
||||
workspaceMembers?: Array<{ user: Candidate | null }>;
|
||||
}) {
|
||||
dbMock.project.findUnique.mockResolvedValue({
|
||||
owner: options.owner ?? null,
|
||||
members: options.members ?? [],
|
||||
workspace: {
|
||||
owner: options.workspaceOwner ?? null,
|
||||
members: options.workspaceMembers ?? [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.project.findUnique.mockReset();
|
||||
});
|
||||
|
||||
describe('getApprovalCandidatesForProject', () => {
|
||||
it('returns null when the project does not exist', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(getApprovalCandidatesForProject('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('collects the project owner, the workspace owner and both member lists', async () => {
|
||||
mockProject({
|
||||
owner: user('u-owner', 'Owner'),
|
||||
workspaceOwner: user('u-ws-owner', 'Workspace Owner'),
|
||||
members: [{ user: user('u-pm', 'Project Member') }],
|
||||
workspaceMembers: [{ user: user('u-wm', 'Workspace Member') }],
|
||||
});
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates?.map((c) => c.id).sort()).toEqual(['u-owner', 'u-pm', 'u-wm', 'u-ws-owner']);
|
||||
});
|
||||
|
||||
it('deduplicates a user who owns both the project and the workspace', async () => {
|
||||
const owner = user('u-owner', 'Owner');
|
||||
mockProject({ owner, workspaceOwner: owner });
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates?.[0].id).toBe('u-owner');
|
||||
});
|
||||
|
||||
it('deduplicates a user listed as both a project and a workspace member', async () => {
|
||||
const member = user('u-both', 'Both');
|
||||
mockProject({
|
||||
owner: user('u-owner', 'Owner'),
|
||||
members: [{ user: member }],
|
||||
workspaceMembers: [{ user: member }],
|
||||
});
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates?.filter((c) => c.id === 'u-both')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the last record seen for a duplicated id', async () => {
|
||||
mockProject({
|
||||
owner: user('u-dup', 'Stale Name'),
|
||||
workspaceMembers: [{ user: user('u-dup', 'Fresh Name') }],
|
||||
});
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates?.[0].name).toBe('Fresh Name');
|
||||
});
|
||||
|
||||
it('sorts by display name case-insensitively', async () => {
|
||||
mockProject({
|
||||
owner: user('u1', 'zoe'),
|
||||
workspaceOwner: user('u2', 'Adam'),
|
||||
members: [{ user: user('u3', 'mike') }],
|
||||
workspaceMembers: [{ user: user('u4', 'Bella') }],
|
||||
});
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates?.map((c) => c.name)).toEqual(['Adam', 'Bella', 'mike', 'zoe']);
|
||||
});
|
||||
|
||||
it('sorts by email when a candidate has no display name', async () => {
|
||||
mockProject({
|
||||
owner: user('u1', null, '[email protected]'),
|
||||
workspaceOwner: user('u2', 'Bella', '[email protected]'),
|
||||
});
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates?.map((c) => c.id)).toEqual(['u1', 'u2']);
|
||||
});
|
||||
|
||||
it('sorts a candidate with neither name nor email first', async () => {
|
||||
mockProject({
|
||||
owner: user('u-blank', null, null),
|
||||
workspaceOwner: user('u-named', 'Adam'),
|
||||
});
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates?.map((c) => c.id)).toEqual(['u-blank', 'u-named']);
|
||||
});
|
||||
|
||||
it('skips null owner and null member user rows without throwing', async () => {
|
||||
mockProject({
|
||||
owner: null,
|
||||
workspaceOwner: null,
|
||||
members: [{ user: null }, { user: user('u-real', 'Real') }],
|
||||
workspaceMembers: [{ user: null }],
|
||||
});
|
||||
|
||||
const candidates = await getApprovalCandidatesForProject('p1');
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{ id: 'u-real', name: 'Real', email: '[email protected]', image: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when the project has no people attached at all', async () => {
|
||||
mockProject({});
|
||||
|
||||
await expect(getApprovalCandidatesForProject('p1')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('queries by project id', async () => {
|
||||
mockProject({ owner: user('u1', 'Owner') });
|
||||
|
||||
await getApprovalCandidatesForProject('project-42');
|
||||
|
||||
expect(dbMock.project.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'project-42' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { runWithConcurrency } from '@/lib/async-pool';
|
||||
|
||||
function tick(ms = 1): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
describe('runWithConcurrency', () => {
|
||||
it('never calls the worker for an empty list', async () => {
|
||||
const worker = vi.fn();
|
||||
|
||||
await runWithConcurrency([], 4, worker);
|
||||
|
||||
expect(worker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processes every item exactly once', async () => {
|
||||
const items = ['a', 'b', 'c', 'd', 'e'];
|
||||
const seen: string[] = [];
|
||||
|
||||
await runWithConcurrency(items, 2, async (item) => {
|
||||
await tick();
|
||||
seen.push(item);
|
||||
});
|
||||
|
||||
expect([...seen].sort()).toEqual([...items].sort());
|
||||
expect(seen).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('starts items in input order even though they finish out of order', async () => {
|
||||
const started: number[] = [];
|
||||
|
||||
await runWithConcurrency([1, 2, 3, 4, 5, 6], 3, async (item) => {
|
||||
started.push(item);
|
||||
await tick(7 - item);
|
||||
});
|
||||
|
||||
expect(started.slice(0, 3)).toEqual([1, 2, 3]);
|
||||
expect([...started].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it('never exceeds the requested concurrency', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
|
||||
await runWithConcurrency(
|
||||
Array.from({ length: 12 }, (_unused, i) => i),
|
||||
3,
|
||||
async () => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
inFlight -= 1;
|
||||
}
|
||||
);
|
||||
|
||||
expect(peak).toBe(3);
|
||||
expect(inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it('reaches the requested concurrency rather than serialising', async () => {
|
||||
let peak = 0;
|
||||
let inFlight = 0;
|
||||
|
||||
await runWithConcurrency(
|
||||
Array.from({ length: 20 }, (_unused, i) => i),
|
||||
5,
|
||||
async () => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
inFlight -= 1;
|
||||
}
|
||||
);
|
||||
|
||||
expect(peak).toBe(5);
|
||||
});
|
||||
|
||||
it('caps the worker count at the number of items', async () => {
|
||||
let peak = 0;
|
||||
let inFlight = 0;
|
||||
|
||||
await runWithConcurrency([1, 2], 50, async () => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
inFlight -= 1;
|
||||
});
|
||||
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it.each([0, -5, 0.5])('treats the limit %s as a single worker', async (limit) => {
|
||||
let peak = 0;
|
||||
let inFlight = 0;
|
||||
|
||||
await runWithConcurrency([1, 2, 3, 4], limit, async () => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
inFlight -= 1;
|
||||
});
|
||||
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
|
||||
it('floors a fractional limit', async () => {
|
||||
let peak = 0;
|
||||
let inFlight = 0;
|
||||
|
||||
await runWithConcurrency([1, 2, 3, 4, 5, 6], 2.9, async () => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
inFlight -= 1;
|
||||
});
|
||||
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects with the worker error when one item fails', async () => {
|
||||
const failure = new Error('item 3 failed');
|
||||
|
||||
await expect(
|
||||
runWithConcurrency([1, 2, 3, 4], 1, async (item) => {
|
||||
if (item === 3) throw failure;
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it('lets items already dispatched by other workers finish after a failure', async () => {
|
||||
const completed: number[] = [];
|
||||
|
||||
// Two workers: worker A picks item 1 and throws, worker B picks item 2 and
|
||||
// finishes. Promise.all rejects on A, but B's work is not cancelled.
|
||||
await expect(
|
||||
runWithConcurrency([1, 2], 2, async (item) => {
|
||||
if (item === 1) {
|
||||
throw new Error('boom');
|
||||
}
|
||||
await tick();
|
||||
completed.push(item);
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
await tick(5);
|
||||
expect(completed).toEqual([2]);
|
||||
});
|
||||
|
||||
it('stops pulling new items in the worker that threw', async () => {
|
||||
const seen: number[] = [];
|
||||
|
||||
await expect(
|
||||
runWithConcurrency([1, 2, 3, 4, 5], 1, async (item) => {
|
||||
seen.push(item);
|
||||
if (item === 2) throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(seen).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('resolves to undefined rather than a result array', async () => {
|
||||
await expect(runWithConcurrency([1, 2], 2, async () => {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets the caller preserve input order by writing into an indexed array', async () => {
|
||||
const items = ['a', 'b', 'c', 'd'];
|
||||
const results: string[] = [];
|
||||
|
||||
await runWithConcurrency(
|
||||
items.map((value, index) => ({ value, index })),
|
||||
3,
|
||||
async ({ value, index }) => {
|
||||
await tick(4 - index);
|
||||
results[index] = value.toUpperCase();
|
||||
}
|
||||
);
|
||||
|
||||
expect(results).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
|
||||
function bunny(attempted: number, failed: number, failedIds: string[] = []) {
|
||||
return { attempted, failed, failedIds };
|
||||
}
|
||||
|
||||
function r2(attempted: number, failed: number, failedKeys: string[] = []) {
|
||||
return { attempted, failed, failedKeys };
|
||||
}
|
||||
|
||||
describe('buildCleanupWarnings', () => {
|
||||
it('returns undefined when nothing was attempted', () => {
|
||||
expect(buildCleanupWarnings({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when both providers succeeded', () => {
|
||||
expect(buildCleanupWarnings({ bunny: bunny(3, 0), r2: r2(5, 0) })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when nothing was attempted on either provider', () => {
|
||||
expect(buildCleanupWarnings({ bunny: bunny(0, 0), r2: r2(0, 0) })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports only Bunny when only Bunny failed', () => {
|
||||
expect(buildCleanupWarnings({ bunny: bunny(4, 1, ['vid-1']), r2: r2(2, 0) })).toEqual({
|
||||
bunny: { attempted: 4, failed: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports only R2 when only R2 failed', () => {
|
||||
expect(buildCleanupWarnings({ bunny: bunny(4, 0), r2: r2(2, 2, ['a', 'b']) })).toEqual({
|
||||
r2: { attempted: 2, failed: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports both providers when both failed', () => {
|
||||
expect(buildCleanupWarnings({ bunny: bunny(4, 1), r2: r2(2, 2) })).toEqual({
|
||||
bunny: { attempted: 4, failed: 1 },
|
||||
r2: { attempted: 2, failed: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the failed id lists from the client-facing summary', () => {
|
||||
const warnings = buildCleanupWarnings({ bunny: bunny(1, 1, ['secret-video-id']) });
|
||||
|
||||
expect(JSON.stringify(warnings)).not.toContain('secret-video-id');
|
||||
expect(Object.keys(warnings!.bunny!).sort()).toEqual(['attempted', 'failed']);
|
||||
});
|
||||
|
||||
it('reports a failure even when the attempted count is inconsistent', () => {
|
||||
expect(buildCleanupWarnings({ r2: r2(0, 1) })).toEqual({ r2: { attempted: 0, failed: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('logCleanupWarnings', () => {
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
const context = { entityType: 'video', entityId: 'video-1' };
|
||||
|
||||
it('logs nothing when both providers succeeded', () => {
|
||||
logCleanupWarnings(context, { bunny: bunny(2, 0), r2: r2(2, 0) });
|
||||
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs nothing when no provider result is supplied', () => {
|
||||
logCleanupWarnings(context, {});
|
||||
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs one entry per failing provider with the entity context', () => {
|
||||
logCleanupWarnings(context, { bunny: bunny(3, 1, ['vid-1']), r2: r2(4, 2, ['k1', 'k2']) });
|
||||
|
||||
expect(consoleError).toHaveBeenCalledTimes(2);
|
||||
expect(consoleError.mock.calls[0][1]).toMatchObject({
|
||||
entityType: 'video',
|
||||
entityId: 'video-1',
|
||||
provider: 'bunny',
|
||||
operation: 'delete',
|
||||
attempted: 3,
|
||||
failed: 1,
|
||||
failedIds: ['vid-1'],
|
||||
});
|
||||
expect(consoleError.mock.calls[1][1]).toMatchObject({
|
||||
provider: 'r2',
|
||||
attempted: 4,
|
||||
failed: 2,
|
||||
failedKeys: ['k1', 'k2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates the failed id list to ten entries', () => {
|
||||
const ids = Array.from({ length: 25 }, (_unused, i) => `vid-${i}`);
|
||||
logCleanupWarnings(context, { bunny: bunny(25, 25, ids) });
|
||||
|
||||
const logged = consoleError.mock.calls[0][1] as { failedIds: string[] };
|
||||
expect(logged.failedIds).toHaveLength(10);
|
||||
expect(logged.failedIds[0]).toBe('vid-0');
|
||||
expect(logged.failedIds[9]).toBe('vid-9');
|
||||
});
|
||||
|
||||
it('truncates the failed key list to ten entries', () => {
|
||||
const keys = Array.from({ length: 11 }, (_unused, i) => `key-${i}`);
|
||||
logCleanupWarnings(context, { r2: r2(11, 11, keys) });
|
||||
|
||||
expect((consoleError.mock.calls[0][1] as { failedKeys: string[] }).failedKeys).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('uses a stable message prefix so the logs can be grepped', () => {
|
||||
logCleanupWarnings(context, { r2: r2(1, 1, ['k']) });
|
||||
|
||||
expect(consoleError.mock.calls[0][0]).toBe('External cleanup warning');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildCommentsCsv,
|
||||
buildCommentsPdf,
|
||||
buildExportFileBaseName,
|
||||
flattenCommentsForExport,
|
||||
type ExportCommentRow,
|
||||
} from '@/lib/comment-export';
|
||||
|
||||
type FlattenInput = Parameters<typeof flattenCommentsForExport>[0];
|
||||
type InputComment = FlattenInput[number];
|
||||
type InputReply = InputComment['replies'][number];
|
||||
|
||||
function reply(overrides: Partial<InputReply> = {}): InputReply {
|
||||
return {
|
||||
id: 'reply-1',
|
||||
parentId: 'comment-1',
|
||||
content: 'A reply',
|
||||
timestamp: 5,
|
||||
timestampEnd: null,
|
||||
isResolved: false,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
annotationData: null,
|
||||
createdAt: new Date('2026-01-15T10:00:00.000Z'),
|
||||
author: { name: 'Replier' },
|
||||
guestName: null,
|
||||
tag: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function comment(overrides: Partial<InputComment> = {}): InputComment {
|
||||
return {
|
||||
id: 'comment-1',
|
||||
parentId: null,
|
||||
content: 'Looks good',
|
||||
timestamp: 12.5,
|
||||
timestampEnd: null,
|
||||
isResolved: false,
|
||||
voiceUrl: null,
|
||||
voiceDuration: null,
|
||||
imageUrl: null,
|
||||
annotationData: null,
|
||||
createdAt: new Date('2026-01-15T09:00:00.000Z'),
|
||||
author: { name: 'Alice' },
|
||||
guestName: null,
|
||||
tag: null,
|
||||
replies: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function row(overrides: Partial<ExportCommentRow> = {}): ExportCommentRow {
|
||||
return {
|
||||
commentId: 'comment-1',
|
||||
parentCommentId: null,
|
||||
level: 0,
|
||||
authorName: 'Alice',
|
||||
authorType: 'user',
|
||||
content: 'Looks good',
|
||||
timestamp: 12.5,
|
||||
timestampEnd: null,
|
||||
tag: '',
|
||||
isResolved: false,
|
||||
hasVoiceNote: false,
|
||||
voiceDuration: null,
|
||||
hasImageAttachment: false,
|
||||
hasAnnotation: false,
|
||||
createdAtIso: '2026-01-15T09:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type ExportMeta = Parameters<typeof buildCommentsCsv>[1];
|
||||
|
||||
const META: ExportMeta = { videoTitle: 'My Video', versionNumber: 2, versionLabel: 'Rough cut' };
|
||||
|
||||
function csvRows(rows: ExportCommentRow[], meta = META): string[][] {
|
||||
// Splitting on newlines is only valid for rows whose cells carry no newline,
|
||||
// so the multiline test parses its own output instead of using this helper.
|
||||
return buildCommentsCsv(rows, meta)
|
||||
.split('\n')
|
||||
.map((line) => line.split(','));
|
||||
}
|
||||
|
||||
describe('buildExportFileBaseName', () => {
|
||||
it.each([
|
||||
['My Video', 2, 'my-video-v2-comments'],
|
||||
['My Video', 1, 'my-video-v1-comments'],
|
||||
['A---B', 1, 'a-b-v1-comments'],
|
||||
['Trailing spaces ', 3, 'trailing-spaces-v3-comments'],
|
||||
[' Leading spaces', 3, 'leading-spaces-v3-comments'],
|
||||
['Version 2.0 (final)', 7, 'version-2-0-final-v7-comments'],
|
||||
])('turns %s v%s into %s', (title, version, expected) => {
|
||||
expect(buildExportFileBaseName(title, version)).toBe(expected);
|
||||
});
|
||||
|
||||
it('falls back to a generic segment when the title has no usable characters', () => {
|
||||
expect(buildExportFileBaseName('!!!', 4)).toBe('comments-v4-comments');
|
||||
expect(buildExportFileBaseName('', 4)).toBe('comments-v4-comments');
|
||||
});
|
||||
|
||||
it('strips non-ascii letters rather than transliterating them', () => {
|
||||
expect(buildExportFileBaseName('Ünlü Vidéo', 1)).toBe('nl-vid-o-v1-comments');
|
||||
});
|
||||
|
||||
it('never lets a path separator survive into the file name', () => {
|
||||
expect(buildExportFileBaseName('../../etc/passwd', 1)).toBe('etc-passwd-v1-comments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenCommentsForExport', () => {
|
||||
it('emits each comment immediately followed by its replies', () => {
|
||||
const rows = flattenCommentsForExport([
|
||||
comment({
|
||||
id: 'c1',
|
||||
replies: [reply({ id: 'r1', parentId: 'c1' }), reply({ id: 'r2', parentId: 'c1' })],
|
||||
}),
|
||||
comment({ id: 'c2', replies: [reply({ id: 'r3', parentId: 'c2' })] }),
|
||||
]);
|
||||
|
||||
expect(rows.map((entry) => entry.commentId)).toEqual(['c1', 'r1', 'r2', 'c2', 'r3']);
|
||||
expect(rows.map((entry) => entry.level)).toEqual([0, 1, 1, 0, 1]);
|
||||
expect(rows.map((entry) => entry.parentCommentId)).toEqual([null, 'c1', 'c1', null, 'c2']);
|
||||
});
|
||||
|
||||
it('sets the parent id from the enclosing comment, not from the reply row', () => {
|
||||
const rows = flattenCommentsForExport([
|
||||
comment({ id: 'c1', replies: [reply({ id: 'r1', parentId: 'stale-parent' })] }),
|
||||
]);
|
||||
|
||||
expect(rows[1].parentCommentId).toBe('c1');
|
||||
});
|
||||
|
||||
it('prefers the account name over the guest name', () => {
|
||||
const rows = flattenCommentsForExport([
|
||||
comment({ author: { name: 'Alice' }, guestName: 'Guest Alice' }),
|
||||
]);
|
||||
|
||||
expect(rows[0].authorName).toBe('Alice');
|
||||
expect(rows[0].authorType).toBe('user');
|
||||
});
|
||||
|
||||
it('falls back to the guest name when there is no account', () => {
|
||||
const rows = flattenCommentsForExport([comment({ author: null, guestName: 'Guest Bob' })]);
|
||||
|
||||
expect(rows[0].authorName).toBe('Guest Bob');
|
||||
expect(rows[0].authorType).toBe('guest');
|
||||
});
|
||||
|
||||
it('falls back to Anonymous when neither name is present', () => {
|
||||
const rows = flattenCommentsForExport([comment({ author: null, guestName: null })]);
|
||||
|
||||
expect(rows[0].authorName).toBe('Anonymous');
|
||||
expect(rows[0].authorType).toBe('guest');
|
||||
});
|
||||
|
||||
it('keeps authorType as user when the account has no display name', () => {
|
||||
const rows = flattenCommentsForExport([
|
||||
comment({ author: { name: null }, guestName: 'Guest Bob' }),
|
||||
]);
|
||||
|
||||
expect(rows[0].authorName).toBe('Guest Bob');
|
||||
expect(rows[0].authorType).toBe('user');
|
||||
});
|
||||
|
||||
it('normalises null content to an empty string', () => {
|
||||
expect(flattenCommentsForExport([comment({ content: null })])[0].content).toBe('');
|
||||
});
|
||||
|
||||
it('normalises a missing tag to an empty string and keeps a present one', () => {
|
||||
const rows = flattenCommentsForExport([
|
||||
comment({ id: 'c1', tag: null }),
|
||||
comment({ id: 'c2', tag: { name: 'Technical' } }),
|
||||
]);
|
||||
|
||||
expect(rows.map((entry) => entry.tag)).toEqual(['', 'Technical']);
|
||||
});
|
||||
|
||||
it('reduces attachment fields to booleans while keeping the voice duration', () => {
|
||||
const rows = flattenCommentsForExport([
|
||||
comment({
|
||||
voiceUrl: 'https://cdn/voice.webm',
|
||||
voiceDuration: 4.25,
|
||||
imageUrl: 'https://cdn/shot.png',
|
||||
annotationData: '[{"points":[]}]',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
hasVoiceNote: true,
|
||||
hasImageAttachment: true,
|
||||
hasAnnotation: true,
|
||||
voiceDuration: 4.25,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty annotation string as no annotation', () => {
|
||||
expect(flattenCommentsForExport([comment({ annotationData: '' })])[0].hasAnnotation).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('serialises createdAt as an ISO string', () => {
|
||||
const rows = flattenCommentsForExport([
|
||||
comment({ createdAt: new Date('2026-03-04T05:06:07.008Z') }),
|
||||
]);
|
||||
|
||||
expect(rows[0].createdAtIso).toBe('2026-03-04T05:06:07.008Z');
|
||||
});
|
||||
|
||||
it('returns an empty list for no comments', () => {
|
||||
expect(flattenCommentsForExport([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('carries the timestamp range through unchanged', () => {
|
||||
const rows = flattenCommentsForExport([comment({ timestamp: 12.5, timestampEnd: 18 })]);
|
||||
|
||||
expect(rows[0].timestamp).toBe(12.5);
|
||||
expect(rows[0].timestampEnd).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommentsCsv', () => {
|
||||
it('writes a fully quoted header row with 19 columns', () => {
|
||||
const header = csvRows([])[0];
|
||||
|
||||
expect(header).toHaveLength(19);
|
||||
expect(header[0]).toBe('"video_title"');
|
||||
expect(header[header.length - 1]).toBe('"created_at_iso"');
|
||||
expect(header.every((cell) => cell.startsWith('"') && cell.endsWith('"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('emits one line per row plus the header', () => {
|
||||
expect(buildCommentsCsv([row(), row({ commentId: 'c2' })], META).split('\n')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('repeats the video and version metadata on every row', () => {
|
||||
const lines = csvRows([row(), row({ commentId: 'c2' })]);
|
||||
|
||||
expect(lines[1].slice(0, 3)).toEqual(['"My Video"', '"2"', '"Rough cut"']);
|
||||
expect(lines[2].slice(0, 3)).toEqual(['"My Video"', '"2"', '"Rough cut"']);
|
||||
});
|
||||
|
||||
it('writes an empty cell for a null version label', () => {
|
||||
const lines = csvRows([row()], { ...META, versionLabel: null });
|
||||
|
||||
expect(lines[1][2]).toBe('""');
|
||||
});
|
||||
|
||||
it('doubles embedded double quotes', () => {
|
||||
const csv = buildCommentsCsv([row({ content: 'He said "ship it"' })], META);
|
||||
|
||||
expect(csv).toContain('"He said ""ship it"""');
|
||||
});
|
||||
|
||||
it('keeps a newline inside the quoted content cell', () => {
|
||||
const csv = buildCommentsCsv([row({ content: 'line one\nline two' })], META);
|
||||
|
||||
expect(csv).toContain('"line one\nline two"');
|
||||
});
|
||||
|
||||
it.each(['=SUM(A1:A9)', '+1+1', '-2+3', '@import', ' =cmd|calc', '\t=danger'])(
|
||||
'neutralises the spreadsheet formula %s with a leading apostrophe',
|
||||
(content) => {
|
||||
const csv = buildCommentsCsv([row({ content })], META);
|
||||
|
||||
expect(csv).toContain(`"'${content}"`);
|
||||
}
|
||||
);
|
||||
|
||||
it('leaves ordinary content untouched', () => {
|
||||
const csv = buildCommentsCsv([row({ content: 'Fix the audio at 0:12' })], META);
|
||||
|
||||
expect(csv).toContain('"Fix the audio at 0:12"');
|
||||
expect(csv).not.toContain('"\'Fix');
|
||||
});
|
||||
|
||||
it('writes the raw timestamp with three decimals', () => {
|
||||
expect(csvRows([row({ timestamp: 12.5 })])[1][8]).toBe('"12.500"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, '0:00'],
|
||||
[9, '0:09'],
|
||||
[59.9, '0:59'],
|
||||
[60, '1:00'],
|
||||
[65, '1:05'],
|
||||
[599, '9:59'],
|
||||
[3599, '59:59'],
|
||||
[3600, '1:00:00'],
|
||||
[3725, '1:02:05'],
|
||||
[36000, '10:00:00'],
|
||||
])('formats %s seconds as %s', (timestamp, expected) => {
|
||||
expect(csvRows([row({ timestamp })])[1][9]).toBe(`"${expected}"`);
|
||||
});
|
||||
|
||||
it('writes an empty cell for a null timestamp end and voice duration', () => {
|
||||
const line = csvRows([row({ timestampEnd: null, voiceDuration: null })])[1];
|
||||
|
||||
expect(line[10]).toBe('""');
|
||||
expect(line[15]).toBe('""');
|
||||
});
|
||||
|
||||
it('writes three decimals for a present timestamp end and voice duration', () => {
|
||||
const line = csvRows([row({ timestampEnd: 18, voiceDuration: 4.25 })])[1];
|
||||
|
||||
expect(line[10]).toBe('"18.000"');
|
||||
expect(line[15]).toBe('"4.250"');
|
||||
});
|
||||
|
||||
it('writes booleans as the literals true and false', () => {
|
||||
const line = csvRows([
|
||||
row({
|
||||
isResolved: true,
|
||||
hasVoiceNote: false,
|
||||
hasImageAttachment: true,
|
||||
hasAnnotation: false,
|
||||
}),
|
||||
])[1];
|
||||
|
||||
expect(line[11]).toBe('"true"');
|
||||
expect(line[14]).toBe('"false"');
|
||||
expect(line[16]).toBe('"true"');
|
||||
expect(line[17]).toBe('"false"');
|
||||
});
|
||||
|
||||
it('neutralises a negative timestamp because it starts with a minus sign', () => {
|
||||
// Documents an interaction between the formula guard and numeric cells.
|
||||
expect(csvRows([row({ timestamp: -1 })])[1][8]).toBe(`"'-1.000"`);
|
||||
});
|
||||
|
||||
it('preserves the flattened thread order in the output', () => {
|
||||
const lines = csvRows([
|
||||
row({ commentId: 'c1' }),
|
||||
row({ commentId: 'r1', parentCommentId: 'c1', level: 1 }),
|
||||
row({ commentId: 'c2' }),
|
||||
]);
|
||||
|
||||
expect(lines.slice(1).map((line) => line[3])).toEqual(['"c1"', '"r1"', '"c2"']);
|
||||
expect(lines.slice(1).map((line) => line[5])).toEqual(['"0"', '"1"', '"0"']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommentsPdf', () => {
|
||||
function asText(rows: ExportCommentRow[], meta = META): string {
|
||||
return buildCommentsPdf(rows, meta).toString('utf8');
|
||||
}
|
||||
|
||||
it('produces a PDF 1.4 document with a trailer', () => {
|
||||
const pdf = asText([row()]);
|
||||
|
||||
expect(pdf.startsWith('%PDF-1.4\n')).toBe(true);
|
||||
expect(pdf.endsWith('%%EOF')).toBe(true);
|
||||
expect(pdf).toContain('/Type /Catalog');
|
||||
expect(pdf).toContain('startxref');
|
||||
});
|
||||
|
||||
it('returns a Buffer', () => {
|
||||
expect(Buffer.isBuffer(buildCommentsPdf([row()], META))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the entry count and the version label in the header block', () => {
|
||||
const pdf = asText([row(), row({ commentId: 'c2' })]);
|
||||
|
||||
expect(pdf).toContain('Total Entries: 2');
|
||||
expect(pdf).toContain('Version: v2 \\(Rough cut\\)');
|
||||
});
|
||||
|
||||
it('omits the parenthesised label when there is none', () => {
|
||||
const pdf = asText([row()], { ...META, versionLabel: null });
|
||||
|
||||
expect(pdf).toContain('Version: v2');
|
||||
expect(pdf).not.toContain('Rough cut');
|
||||
});
|
||||
|
||||
it('escapes backslashes and parentheses in the content stream', () => {
|
||||
const pdf = asText([row({ content: 'path C:\\temp (draft)' })]);
|
||||
|
||||
expect(pdf).toContain('path C:\\\\temp \\(draft\\)');
|
||||
});
|
||||
|
||||
it('replaces non-ascii characters with a question mark', () => {
|
||||
const pdf = asText([row({ content: 'Ünlü emoji test' })]);
|
||||
|
||||
expect(pdf).toContain('?nl? emoji test');
|
||||
});
|
||||
|
||||
it('marks a reply row differently from a top-level comment', () => {
|
||||
const pdf = asText([row({ level: 1, authorName: 'Replier' })]);
|
||||
|
||||
expect(pdf).toContain('Reply');
|
||||
});
|
||||
|
||||
it('renders the resolved, voice, image and annotation flags', () => {
|
||||
const pdf = asText([
|
||||
row({ isResolved: true, hasVoiceNote: true, hasImageAttachment: false, tag: 'Urgent' }),
|
||||
]);
|
||||
|
||||
expect(pdf).toContain('resolved=yes');
|
||||
expect(pdf).toContain('voice=yes');
|
||||
expect(pdf).toContain('image=no');
|
||||
expect(pdf).toContain('tag=Urgent');
|
||||
});
|
||||
|
||||
it('omits the tag detail when the row has no tag', () => {
|
||||
expect(asText([row({ tag: '' })])).not.toContain('tag=');
|
||||
});
|
||||
|
||||
it('produces a single page document for a short export', () => {
|
||||
expect(asText([row()])).toContain('/Count 1');
|
||||
});
|
||||
|
||||
it('paginates once the line budget is exceeded', () => {
|
||||
const rows = Array.from({ length: 40 }, (_unused, i) => row({ commentId: `c${i}` }));
|
||||
const pdf = asText(rows);
|
||||
const count = /\/Count (\d+)/.exec(pdf)?.[1];
|
||||
|
||||
expect(Number(count)).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('still produces a valid document with no comments at all', () => {
|
||||
const pdf = asText([]);
|
||||
|
||||
expect(pdf).toContain('Total Entries: 0');
|
||||
expect(pdf).toContain('/Count 1');
|
||||
expect(pdf.endsWith('%%EOF')).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps a very long comment across several text lines', () => {
|
||||
const longWord = 'x'.repeat(300);
|
||||
const pdf = asText([row({ content: longWord })]);
|
||||
const chunks = pdf.match(/x{90,}/g) ?? [];
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk.length <= 96)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
||||
|
||||
describe('DEFAULT_COMMENT_TAGS', () => {
|
||||
it('seeds a non-empty set of tags', () => {
|
||||
expect(DEFAULT_COMMENT_TAGS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has a unique name per tag', () => {
|
||||
const names = DEFAULT_COMMENT_TAGS.map((tag) => tag.name);
|
||||
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('has a unique name per tag even when compared case-insensitively', () => {
|
||||
const names = DEFAULT_COMMENT_TAGS.map((tag) => tag.name.toLowerCase());
|
||||
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('gives every tag a trimmed, non-empty name', () => {
|
||||
for (const tag of DEFAULT_COMMENT_TAGS) {
|
||||
expect(tag.name.trim()).toBe(tag.name);
|
||||
expect(tag.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every tag a six digit hex colour', () => {
|
||||
for (const tag of DEFAULT_COMMENT_TAGS) {
|
||||
expect(tag.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a distinct colour per tag so the UI can tell them apart', () => {
|
||||
const colors = DEFAULT_COMMENT_TAGS.map((tag) => tag.color.toUpperCase());
|
||||
|
||||
expect(new Set(colors).size).toBe(colors.length);
|
||||
});
|
||||
|
||||
it('numbers the positions contiguously from zero in array order', () => {
|
||||
expect(DEFAULT_COMMENT_TAGS.map((tag) => tag.position)).toEqual(
|
||||
DEFAULT_COMMENT_TAGS.map((_tag, index) => index)
|
||||
);
|
||||
});
|
||||
|
||||
it('uses colours the annotation validator would also accept', () => {
|
||||
// Comment tag colours and annotation stroke colours share the same 6-digit
|
||||
// hex convention, so seed data cannot drift away from the validator.
|
||||
for (const tag of DEFAULT_COMMENT_TAGS) {
|
||||
expect(/^#[0-9a-fA-F]{6}$/.test(tag.color)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildContentSecurityPolicy } from '@/lib/content-security-policy';
|
||||
|
||||
const MANAGED_ENV = [
|
||||
'BUNNY_CDN_URL',
|
||||
'NEXT_PUBLIC_BUNNY_CDN_URL',
|
||||
'R2_ENDPOINT',
|
||||
'R2_PRESIGN_ENDPOINT',
|
||||
'R2_PUBLIC_BASE_URL',
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_BUCKET_NAME',
|
||||
];
|
||||
|
||||
function directives(): Record<string, string[]> {
|
||||
const entries = buildContentSecurityPolicy()
|
||||
.split('; ')
|
||||
.map((part) => {
|
||||
const [name, ...values] = part.split(' ');
|
||||
return [name, values] as const;
|
||||
});
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const name of MANAGED_ENV) {
|
||||
vi.stubEnv(name, undefined);
|
||||
}
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('buildContentSecurityPolicy', () => {
|
||||
it('locks down the directives that never depend on configuration', () => {
|
||||
const csp = directives();
|
||||
|
||||
expect(csp['default-src']).toEqual(["'self'"]);
|
||||
expect(csp['object-src']).toEqual(["'none'"]);
|
||||
expect(csp['base-uri']).toEqual(["'self'"]);
|
||||
expect(csp['form-action']).toEqual(["'self'"]);
|
||||
expect(csp['frame-ancestors']).toEqual(["'none'"]);
|
||||
expect(csp['font-src']).toEqual(["'self'"]);
|
||||
expect(csp['style-src']).toEqual(["'self'", "'unsafe-inline'"]);
|
||||
expect(csp['worker-src']).toEqual(["'self'", 'blob:']);
|
||||
});
|
||||
|
||||
it('emits exactly one entry per directive name', () => {
|
||||
const parts = buildContentSecurityPolicy().split('; ');
|
||||
const names = parts.map((part) => part.split(' ')[0]);
|
||||
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toHaveLength(13);
|
||||
});
|
||||
|
||||
it.each(['production', 'development', 'test'])(
|
||||
'never allows unsafe-eval with NODE_ENV=%s',
|
||||
(nodeEnv) => {
|
||||
vi.stubEnv('NODE_ENV', nodeEnv);
|
||||
expect(buildContentSecurityPolicy()).not.toContain('unsafe-eval');
|
||||
}
|
||||
);
|
||||
|
||||
it('allows the inline hydration scripts and the YouTube iframe API in script-src', () => {
|
||||
expect(directives()['script-src']).toEqual([
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
'https://www.youtube.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows the YouTube and Bunny iframe hosts in frame-src', () => {
|
||||
expect(directives()['frame-src']).toEqual([
|
||||
"'self'",
|
||||
'https://www.youtube.com',
|
||||
'https://iframe.mediadelivery.net',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits any Bunny CDN origin when none is configured', () => {
|
||||
const csp = directives();
|
||||
|
||||
expect(csp['media-src']).toEqual(["'self'", 'blob:']);
|
||||
expect(csp['img-src']).toEqual([
|
||||
"'self'",
|
||||
'data:',
|
||||
'blob:',
|
||||
'https://img.youtube.com',
|
||||
'https://i.ytimg.com',
|
||||
'https://images.unsplash.com',
|
||||
'https://vz-thumbnail.b-cdn.net',
|
||||
]);
|
||||
expect(csp['connect-src'].filter((src) => src.includes('b-cdn.net'))).toEqual([]);
|
||||
// An empty cdnOrigin must be filtered out rather than left as a bare token.
|
||||
expect(csp['connect-src']).not.toContain('');
|
||||
});
|
||||
|
||||
it('adds a configured Bunny CDN origin to connect-src, img-src and media-src', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://cdn.example.b-cdn.net');
|
||||
const csp = directives();
|
||||
|
||||
expect(csp['connect-src']).toContain('https://cdn.example.b-cdn.net');
|
||||
expect(csp['img-src']).toContain('https://cdn.example.b-cdn.net');
|
||||
expect(csp['media-src']).toContain('https://cdn.example.b-cdn.net');
|
||||
});
|
||||
|
||||
it('strips a path and trailing slash from the Bunny CDN url', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://cdn.example.b-cdn.net/videos/');
|
||||
|
||||
expect(directives()['media-src']).toContain('https://cdn.example.b-cdn.net');
|
||||
});
|
||||
|
||||
it('upgrades a protocol-less Bunny CDN host to https', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'cdn.example.b-cdn.net');
|
||||
|
||||
expect(directives()['media-src']).toContain('https://cdn.example.b-cdn.net');
|
||||
});
|
||||
|
||||
it('falls back to the public Bunny CDN variable', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(directives()['media-src']).toContain('https://public.b-cdn.net');
|
||||
});
|
||||
|
||||
it('prefers the server Bunny CDN variable when both are set', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
const mediaSrc = directives()['media-src'];
|
||||
|
||||
expect(mediaSrc).toContain('https://server.b-cdn.net');
|
||||
expect(mediaSrc).not.toContain('https://public.b-cdn.net');
|
||||
});
|
||||
|
||||
it('always allows the local MinIO defaults in connect-src', () => {
|
||||
const connectSrc = directives()['connect-src'];
|
||||
|
||||
expect(connectSrc).toContain('http://localhost:9000');
|
||||
expect(connectSrc).toContain('http://127.0.0.1:9000');
|
||||
});
|
||||
|
||||
it('reduces a custom R2 endpoint to its origin', () => {
|
||||
vi.stubEnv('R2_ENDPOINT', 'https://minio.internal:9443/openframe-bucket');
|
||||
|
||||
expect(directives()['connect-src']).toContain('https://minio.internal:9443');
|
||||
});
|
||||
|
||||
it('includes the presign and public base origins', () => {
|
||||
vi.stubEnv('R2_PRESIGN_ENDPOINT', 'https://presign.example.com');
|
||||
vi.stubEnv('R2_PUBLIC_BASE_URL', 'https://public.example.com/assets');
|
||||
const connectSrc = directives()['connect-src'];
|
||||
|
||||
expect(connectSrc).toContain('https://presign.example.com');
|
||||
expect(connectSrc).toContain('https://public.example.com');
|
||||
});
|
||||
|
||||
it('deduplicates identical R2 origins', () => {
|
||||
vi.stubEnv('R2_ENDPOINT', 'https://minio.internal:9443/bucket-a');
|
||||
vi.stubEnv('R2_PRESIGN_ENDPOINT', 'https://minio.internal:9443/bucket-b');
|
||||
const occurrences = directives()['connect-src'].filter(
|
||||
(src) => src === 'https://minio.internal:9443'
|
||||
);
|
||||
|
||||
expect(occurrences).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores an unparseable R2 endpoint instead of throwing', () => {
|
||||
vi.stubEnv('R2_ENDPOINT', 'not a url at all');
|
||||
|
||||
expect(() => buildContentSecurityPolicy()).not.toThrow();
|
||||
expect(directives()['connect-src']).not.toContain('not');
|
||||
});
|
||||
|
||||
it('derives the Cloudflare R2 hosts from the account id', () => {
|
||||
vi.stubEnv('R2_ACCOUNT_ID', 'acct123');
|
||||
const connectSrc = directives()['connect-src'];
|
||||
|
||||
expect(connectSrc).toContain('https://acct123.r2.cloudflarestorage.com');
|
||||
expect(connectSrc).toContain('https://*.r2.cloudflarestorage.com');
|
||||
});
|
||||
|
||||
it('adds the bucket-scoped Cloudflare R2 host when a bucket name is set', () => {
|
||||
vi.stubEnv('R2_ACCOUNT_ID', 'acct123');
|
||||
vi.stubEnv('R2_BUCKET_NAME', 'openframe');
|
||||
|
||||
expect(directives()['connect-src']).toContain(
|
||||
'https://openframe.acct123.r2.cloudflarestorage.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit a Cloudflare R2 host from a bucket name alone', () => {
|
||||
vi.stubEnv('R2_BUCKET_NAME', 'openframe');
|
||||
|
||||
expect(
|
||||
directives()['connect-src'].some((src) => src.includes('r2.cloudflarestorage.com'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows the Next.js HMR websocket only in development', () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
expect(buildContentSecurityPolicy()).toContain('ws://localhost:*');
|
||||
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
expect(buildContentSecurityPolicy()).not.toContain('ws://localhost:*');
|
||||
});
|
||||
|
||||
it('keeps the YouTube thumbnail hosts in img-src', () => {
|
||||
const imgSrc = directives()['img-src'];
|
||||
|
||||
expect(imgSrc).toContain('https://img.youtube.com');
|
||||
expect(imgSrc).toContain('https://i.ytimg.com');
|
||||
expect(imgSrc).toContain('data:');
|
||||
expect(imgSrc).toContain('blob:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EMAIL_COLORS,
|
||||
brandedEmailTemplate,
|
||||
emailButton,
|
||||
emailHeading,
|
||||
emailHighlight,
|
||||
emailRow,
|
||||
escapeAttr,
|
||||
escapeHtml,
|
||||
} from '@/lib/email-brand';
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it.each([
|
||||
['&', '&'],
|
||||
['<', '<'],
|
||||
['>', '>'],
|
||||
['"', '"'],
|
||||
])('escapes %s as %s', (input, expected) => {
|
||||
expect(escapeHtml(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('neutralises a script tag', () => {
|
||||
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
|
||||
'<script>alert("xss")</script>'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes the ampersand first so an existing entity is not double-decoded', () => {
|
||||
expect(escapeHtml('<')).toBe('&lt;');
|
||||
});
|
||||
|
||||
it('leaves a single quote unescaped', () => {
|
||||
// Documents the current behaviour: values interpolated into single-quoted
|
||||
// attributes are not protected by this helper.
|
||||
expect(escapeHtml("it's")).toBe("it's");
|
||||
});
|
||||
|
||||
it('leaves plain text untouched', () => {
|
||||
expect(escapeHtml('Alice reviewed your video')).toBe('Alice reviewed your video');
|
||||
});
|
||||
|
||||
it('returns an empty string unchanged', () => {
|
||||
expect(escapeHtml('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeAttr', () => {
|
||||
it('escapes the same four characters as escapeHtml', () => {
|
||||
expect(escapeAttr('&<>"')).toBe('&<>"');
|
||||
});
|
||||
|
||||
it('breaks an attribute injection attempt', () => {
|
||||
const escaped = escapeAttr('https://x.com" onmouseover="alert(1)');
|
||||
|
||||
expect(escaped).not.toContain('" onmouseover');
|
||||
expect(escaped).toContain('" onmouseover="');
|
||||
});
|
||||
|
||||
it('agrees with escapeHtml on every input despite the different replacement order', () => {
|
||||
for (const input of ['&', '<', '>', '"', '<', 'a&b<c>d"e']) {
|
||||
expect(escapeAttr(input)).toBe(escapeHtml(input));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('brandedEmailTemplate', () => {
|
||||
it('produces a full HTML document carrying the brand colours', () => {
|
||||
const html = brandedEmailTemplate('<td>Body</td>');
|
||||
|
||||
expect(html.startsWith('<!DOCTYPE html>')).toBe(true);
|
||||
expect(html.trimEnd().endsWith('</html>')).toBe(true);
|
||||
expect(html).toContain(EMAIL_COLORS.bg);
|
||||
expect(html).toContain('OpenFrame');
|
||||
});
|
||||
|
||||
it('inserts the body markup verbatim', () => {
|
||||
expect(brandedEmailTemplate('<td>Hello & welcome</td>')).toContain(
|
||||
'<td>Hello & welcome</td>'
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the footer block when no footer options are given', () => {
|
||||
expect(brandedEmailTemplate('<td>Body</td>')).not.toContain(
|
||||
'padding:20px 0 0;text-align:center'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders footer text on its own', () => {
|
||||
const html = brandedEmailTemplate('<td>Body</td>', { footerText: 'Sent by OpenFrame' });
|
||||
|
||||
expect(html).toContain('Sent by OpenFrame');
|
||||
expect(html).not.toContain('<a href=');
|
||||
});
|
||||
|
||||
it('renders the footer link only when both the text and the url are present', () => {
|
||||
const withTextOnly = brandedEmailTemplate('<td>Body</td>', { footerLinkText: 'Unsubscribe' });
|
||||
const withBoth = brandedEmailTemplate('<td>Body</td>', {
|
||||
footerLinkText: 'Unsubscribe',
|
||||
footerLinkUrl: 'https://open-frame.net/settings',
|
||||
});
|
||||
|
||||
expect(withTextOnly).not.toContain('Unsubscribe');
|
||||
expect(withBoth).toContain('href="https://open-frame.net/settings"');
|
||||
expect(withBoth).toContain('>Unsubscribe<');
|
||||
});
|
||||
|
||||
it('escapes the footer link url as an attribute', () => {
|
||||
const html = brandedEmailTemplate('<td>Body</td>', {
|
||||
footerLinkText: 'Unsubscribe',
|
||||
footerLinkUrl: 'https://x.com" onmouseover="alert(1)',
|
||||
});
|
||||
|
||||
expect(html).not.toContain('" onmouseover="alert(1)"');
|
||||
expect(html).toContain('" onmouseover="alert(1)');
|
||||
});
|
||||
|
||||
it('escapes the footer link text as HTML', () => {
|
||||
const html = brandedEmailTemplate('<td>Body</td>', {
|
||||
footerLinkText: '<script>alert(1)</script>',
|
||||
footerLinkUrl: 'https://open-frame.net',
|
||||
});
|
||||
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('email fragment builders', () => {
|
||||
it('emailHeading renders the icon and title in the accent colour', () => {
|
||||
const html = emailHeading('🎬', 'New comment');
|
||||
|
||||
expect(html).toContain('🎬');
|
||||
expect(html).toContain('New comment');
|
||||
expect(html).toContain(EMAIL_COLORS.accent);
|
||||
});
|
||||
|
||||
it('emailRow renders the label and value in a table row', () => {
|
||||
const html = emailRow('Project', 'Launch video');
|
||||
|
||||
expect(html.startsWith('<tr>')).toBe(true);
|
||||
expect(html).toContain('Project');
|
||||
expect(html).toContain('Launch video');
|
||||
});
|
||||
|
||||
it('emailRow switches to the highlight style when asked', () => {
|
||||
const plain = emailRow('Project', 'Launch video');
|
||||
const highlighted = emailRow('Project', 'Launch video', true);
|
||||
|
||||
expect(plain).toContain(EMAIL_COLORS.textSecondary);
|
||||
expect(highlighted).toContain('font-weight:600');
|
||||
expect(highlighted).not.toContain(EMAIL_COLORS.textSecondary);
|
||||
});
|
||||
|
||||
it('emailButton escapes the href but not the label', () => {
|
||||
const html = emailButton('<b>Open</b>', 'https://x.com" onclick="alert(1)');
|
||||
|
||||
expect(html).toContain('" onclick="alert(1)');
|
||||
// Documents that the label is inserted raw, so callers must escape it.
|
||||
expect(html).toContain('<b>Open</b>');
|
||||
});
|
||||
|
||||
it('emailHighlight wraps the text in a bordered block', () => {
|
||||
const html = emailHighlight('Your trial ends in 2 days');
|
||||
|
||||
expect(html.startsWith('<div')).toBe(true);
|
||||
expect(html).toContain('Your trial ends in 2 days');
|
||||
expect(html).toContain(EMAIL_COLORS.cardInner);
|
||||
});
|
||||
|
||||
it('every brand colour is a six digit hex value', () => {
|
||||
for (const value of Object.values(EMAIL_COLORS)) {
|
||||
expect(value).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
|
||||
describe('normalizeEmail', () => {
|
||||
it.each([
|
||||
[' [email protected] ', '[email protected]'],
|
||||
['[email protected]', '[email protected]'],
|
||||
['\[email protected]\n', '[email protected]'],
|
||||
['[email protected]', '[email protected]'],
|
||||
])('normalises %s to %s', (input, expected) => {
|
||||
expect(normalizeEmail(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not strip internal whitespace', () => {
|
||||
expect(normalizeEmail('a [email protected]')).toBe('a [email protected]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmailAddress', () => {
|
||||
it.each([
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
"o'[email protected]",
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
])('accepts %s', (email) => {
|
||||
expect(isValidEmailAddress(email)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['a two character string', 'a@'],
|
||||
['no at sign', 'userexample.com'],
|
||||
['a leading at sign', '@example.com'],
|
||||
['two at signs', 'user@[email protected]'],
|
||||
['a domain with no dot', 'user@example'],
|
||||
['a domain that is only a dot', 'user@.'],
|
||||
['an empty domain label', '[email protected]'],
|
||||
['a trailing dot', '[email protected].'],
|
||||
['a leading dot in the domain', '[email protected]'],
|
||||
['an internal space', 'user [email protected]'],
|
||||
['a leading space', ' [email protected]'],
|
||||
['a tab', 'user\[email protected]'],
|
||||
['a newline', '[email protected]\n'],
|
||||
['a carriage return', 'user\[email protected]'],
|
||||
['a null byte', 'user\[email protected]'],
|
||||
['a delete character', 'user\[email protected]'],
|
||||
['an empty local part', '@b.co'],
|
||||
])('rejects %s', (_label, email) => {
|
||||
expect(isValidEmailAddress(email)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a local part of exactly 64 characters', () => {
|
||||
expect(isValidEmailAddress(`${'a'.repeat(64)}@example.com`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a local part of 65 characters', () => {
|
||||
expect(isValidEmailAddress(`${'a'.repeat(65)}@example.com`)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a domain label of exactly 63 characters', () => {
|
||||
expect(isValidEmailAddress(`user@${'a'.repeat(63)}.com`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a domain label of 64 characters', () => {
|
||||
expect(isValidEmailAddress(`user@${'a'.repeat(64)}.com`)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an address of exactly 254 characters', () => {
|
||||
const local = 'a'.repeat(64);
|
||||
const domain = `${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`;
|
||||
const email = `${local}@${domain}`;
|
||||
|
||||
expect(email).toHaveLength(254);
|
||||
expect(isValidEmailAddress(email)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an address of 255 characters', () => {
|
||||
const local = 'a'.repeat(64);
|
||||
const domain = `${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(62)}`;
|
||||
const email = `${local}@${domain}`;
|
||||
|
||||
expect(email).toHaveLength(255);
|
||||
expect(isValidEmailAddress(email)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a three character address because the domain cannot hold a dot', () => {
|
||||
// The length floor is 3, so this documents that the domain rule, not the
|
||||
// length rule, is what rejects the shortest inputs.
|
||||
expect(isValidEmailAddress('a@b')).toBe(false);
|
||||
expect(isValidEmailAddress('ab')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getMaxVideoUploadBytes,
|
||||
getR2MultipartPartSizeBytes,
|
||||
getR2MultipartThresholdBytes,
|
||||
hasBunnyUploadsConfig,
|
||||
hasR2Config,
|
||||
hasStripeConfig,
|
||||
isBunnyUploadsEnabled,
|
||||
isBunnyUploadsFeatureEnabled,
|
||||
isDirectFileUploadEnabled,
|
||||
isInviteCodeRequired,
|
||||
isS3VideoUploadsEnabled,
|
||||
isS3VideoUploadsFeatureEnabled,
|
||||
isStripeBillingEnabled,
|
||||
isStripeFeatureEnabled,
|
||||
} from '@/lib/feature-flags';
|
||||
|
||||
const MIB = BigInt(1024) * BigInt(1024);
|
||||
const GIB = MIB * BigInt(1024);
|
||||
|
||||
const MANAGED_ENV = [
|
||||
'OPENFRAME_ENABLE_STRIPE',
|
||||
'OPENFRAME_ENABLE_BUNNY_UPLOADS',
|
||||
'OPENFRAME_ENABLE_S3_VIDEO_UPLOADS',
|
||||
'OPENFRAME_REQUIRE_INVITE_CODE',
|
||||
'OPENFRAME_MAX_VIDEO_UPLOAD_BYTES',
|
||||
'OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES',
|
||||
'OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES',
|
||||
'STRIPE_SECRET_KEY',
|
||||
'STRIPE_PRICE_ID',
|
||||
'BUNNY_STREAM_API_KEY',
|
||||
'BUNNY_STREAM_LIBRARY_ID',
|
||||
'NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_SECRET_ACCESS_KEY',
|
||||
'R2_BUCKET_NAME',
|
||||
'R2_ENDPOINT',
|
||||
'R2_ACCOUNT_ID',
|
||||
];
|
||||
|
||||
function enableR2Config() {
|
||||
vi.stubEnv('R2_ACCESS_KEY_ID', 'key');
|
||||
vi.stubEnv('R2_SECRET_ACCESS_KEY', 'secret');
|
||||
vi.stubEnv('R2_BUCKET_NAME', 'bucket');
|
||||
vi.stubEnv('R2_ACCOUNT_ID', 'account');
|
||||
}
|
||||
|
||||
function enableBunnyConfig() {
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key');
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '12345');
|
||||
}
|
||||
|
||||
// warnIfConflictingDirectUploadFlags() routes through logError, which writes to
|
||||
// console.error. Capture it so the suite stays quiet and the warning is assertable.
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Start from a blank slate so the host environment cannot decide a default.
|
||||
for (const name of MANAGED_ENV) {
|
||||
vi.stubEnv(name, undefined);
|
||||
}
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleError.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('boolean feature flags', () => {
|
||||
it.each([
|
||||
['true', true],
|
||||
['TRUE', true],
|
||||
[' true ', true],
|
||||
['false', false],
|
||||
['FALSE', false],
|
||||
[' False ', false],
|
||||
])('reads OPENFRAME_ENABLE_STRIPE=%s as %s', (raw, expected) => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', raw);
|
||||
expect(isStripeFeatureEnabled()).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['yes', 'no', '1', '0', 'on', 'off', 'maybe', ''])(
|
||||
'falls back to the default for the unrecognised value %s',
|
||||
(raw) => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', raw);
|
||||
vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', raw);
|
||||
// Stripe defaults to on, S3 video uploads default to off.
|
||||
expect(isStripeFeatureEnabled()).toBe(true);
|
||||
expect(isS3VideoUploadsFeatureEnabled()).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('defaults Stripe, Bunny uploads and the invite code to on when unset', () => {
|
||||
expect(isStripeFeatureEnabled()).toBe(true);
|
||||
expect(isBunnyUploadsFeatureEnabled()).toBe(true);
|
||||
expect(isInviteCodeRequired()).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults S3 video uploads to off when unset', () => {
|
||||
expect(isS3VideoUploadsFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('lets OPENFRAME_REQUIRE_INVITE_CODE=false open registration', () => {
|
||||
vi.stubEnv('OPENFRAME_REQUIRE_INVITE_CODE', 'false');
|
||||
expect(isInviteCodeRequired()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasStripeConfig', () => {
|
||||
it('requires both the secret key and the price id', () => {
|
||||
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test');
|
||||
expect(hasStripeConfig()).toBe(false);
|
||||
|
||||
vi.stubEnv('STRIPE_PRICE_ID', 'price_1');
|
||||
expect(hasStripeConfig()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty secret key as missing', () => {
|
||||
vi.stubEnv('STRIPE_SECRET_KEY', '');
|
||||
vi.stubEnv('STRIPE_PRICE_ID', 'price_1');
|
||||
expect(hasStripeConfig()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStripeBillingEnabled', () => {
|
||||
it('needs the flag on and the config present', () => {
|
||||
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test');
|
||||
vi.stubEnv('STRIPE_PRICE_ID', 'price_1');
|
||||
expect(isStripeBillingEnabled()).toBe(true);
|
||||
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||
expect(isStripeBillingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('is off when the flag is on but the config is missing', () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
|
||||
expect(isStripeBillingEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasR2Config', () => {
|
||||
it('accepts an explicit endpoint without an account id', () => {
|
||||
vi.stubEnv('R2_ACCESS_KEY_ID', 'key');
|
||||
vi.stubEnv('R2_SECRET_ACCESS_KEY', 'secret');
|
||||
vi.stubEnv('R2_BUCKET_NAME', 'bucket');
|
||||
vi.stubEnv('R2_ENDPOINT', 'http://localhost:9000');
|
||||
expect(hasR2Config()).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an account id without an explicit endpoint', () => {
|
||||
enableR2Config();
|
||||
expect(hasR2Config()).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET_NAME'])(
|
||||
'is incomplete without %s',
|
||||
(missing) => {
|
||||
enableR2Config();
|
||||
vi.stubEnv(missing, undefined);
|
||||
expect(hasR2Config()).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('is incomplete when neither an endpoint nor an account id is set', () => {
|
||||
vi.stubEnv('R2_ACCESS_KEY_ID', 'key');
|
||||
vi.stubEnv('R2_SECRET_ACCESS_KEY', 'secret');
|
||||
vi.stubEnv('R2_BUCKET_NAME', 'bucket');
|
||||
expect(hasR2Config()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBunnyUploadsConfig', () => {
|
||||
it('accepts the server-side library id', () => {
|
||||
enableBunnyConfig();
|
||||
expect(hasBunnyUploadsConfig()).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the public library id as a substitute', () => {
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', '12345');
|
||||
expect(hasBunnyUploadsConfig()).toBe(true);
|
||||
});
|
||||
|
||||
it('is incomplete without an api key', () => {
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '12345');
|
||||
expect(hasBunnyUploadsConfig()).toBe(false);
|
||||
});
|
||||
|
||||
it('is incomplete without any library id', () => {
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key');
|
||||
expect(hasBunnyUploadsConfig()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct upload precedence', () => {
|
||||
it('gives S3 precedence over Bunny when both are fully configured', () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', 'true');
|
||||
enableR2Config();
|
||||
enableBunnyConfig();
|
||||
|
||||
expect(isS3VideoUploadsEnabled()).toBe(true);
|
||||
expect(isBunnyUploadsEnabled()).toBe(false);
|
||||
expect(isDirectFileUploadEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to Bunny when the S3 flag is on but R2 is not configured', () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', 'true');
|
||||
enableBunnyConfig();
|
||||
|
||||
expect(isS3VideoUploadsEnabled()).toBe(false);
|
||||
expect(isBunnyUploadsEnabled()).toBe(true);
|
||||
expect(isDirectFileUploadEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves S3 off when R2 is configured but the flag is not set', () => {
|
||||
enableR2Config();
|
||||
enableBunnyConfig();
|
||||
|
||||
expect(isS3VideoUploadsEnabled()).toBe(false);
|
||||
expect(isBunnyUploadsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('disables Bunny when its flag is off even with valid config', () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', 'false');
|
||||
enableBunnyConfig();
|
||||
|
||||
expect(isBunnyUploadsEnabled()).toBe(false);
|
||||
expect(isDirectFileUploadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports no direct upload path when nothing is configured', () => {
|
||||
expect(isS3VideoUploadsEnabled()).toBe(false);
|
||||
expect(isBunnyUploadsEnabled()).toBe(false);
|
||||
expect(isDirectFileUploadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('warns at most once when both direct upload backends are fully enabled', async () => {
|
||||
// A fresh module instance resets the module-scoped "already warned" latch.
|
||||
vi.resetModules();
|
||||
vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', 'true');
|
||||
enableR2Config();
|
||||
enableBunnyConfig();
|
||||
|
||||
const flags = await import('@/lib/feature-flags');
|
||||
flags.isS3VideoUploadsEnabled();
|
||||
flags.isS3VideoUploadsEnabled();
|
||||
flags.isDirectFileUploadEnabled();
|
||||
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
expect(String(consoleError.mock.calls[0][0])).toContain('take precedence');
|
||||
});
|
||||
|
||||
it('does not warn when only one direct upload backend is configured', async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', 'true');
|
||||
enableR2Config();
|
||||
|
||||
const flags = await import('@/lib/feature-flags');
|
||||
flags.isDirectFileUploadEnabled();
|
||||
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaxVideoUploadBytes', () => {
|
||||
it('defaults to 5 GiB', () => {
|
||||
expect(getMaxVideoUploadBytes()).toBe(BigInt(5) * GIB);
|
||||
});
|
||||
|
||||
it('uses a valid explicit byte count', () => {
|
||||
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '1073741824');
|
||||
expect(getMaxVideoUploadBytes()).toBe(GIB);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before parsing', () => {
|
||||
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', ' 1073741824 ');
|
||||
expect(getMaxVideoUploadBytes()).toBe(GIB);
|
||||
});
|
||||
|
||||
it.each(['0', '-1', '-1073741824', 'abc', '1.5', '1e9', '1_000', ' '])(
|
||||
'falls back to 5 GiB for the invalid value %s',
|
||||
(raw) => {
|
||||
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', raw);
|
||||
expect(getMaxVideoUploadBytes()).toBe(BigInt(5) * GIB);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('getR2MultipartThresholdBytes', () => {
|
||||
it('defaults to 90 MiB so a single PUT stays under the 100 MB proxy cap', () => {
|
||||
expect(getR2MultipartThresholdBytes()).toBe(BigInt(90) * MIB);
|
||||
});
|
||||
|
||||
it('uses a valid explicit threshold', () => {
|
||||
vi.stubEnv('OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES', '52428800');
|
||||
expect(getR2MultipartThresholdBytes()).toBe(BigInt(50) * MIB);
|
||||
});
|
||||
|
||||
it.each(['0', '-5', 'nonsense'])('falls back to 90 MiB for %s', (raw) => {
|
||||
vi.stubEnv('OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES', raw);
|
||||
expect(getR2MultipartThresholdBytes()).toBe(BigInt(90) * MIB);
|
||||
});
|
||||
|
||||
it('has no lower clamp, unlike the part size', () => {
|
||||
vi.stubEnv('OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES', '1024');
|
||||
expect(getR2MultipartThresholdBytes()).toBe(BigInt(1024));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getR2MultipartPartSizeBytes', () => {
|
||||
it('defaults to 32 MiB', () => {
|
||||
expect(getR2MultipartPartSizeBytes()).toBe(BigInt(32) * MIB);
|
||||
});
|
||||
|
||||
it('clamps a value below the S3 minimum up to 5 MiB', () => {
|
||||
vi.stubEnv('OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES', '1024');
|
||||
expect(getR2MultipartPartSizeBytes()).toBe(BigInt(5) * MIB);
|
||||
});
|
||||
|
||||
it('accepts exactly 5 MiB without clamping', () => {
|
||||
vi.stubEnv('OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES', String(BigInt(5) * MIB));
|
||||
expect(getR2MultipartPartSizeBytes()).toBe(BigInt(5) * MIB);
|
||||
});
|
||||
|
||||
it('accepts a value above the minimum unchanged', () => {
|
||||
vi.stubEnv('OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES', String(BigInt(100) * MIB));
|
||||
expect(getR2MultipartPartSizeBytes()).toBe(BigInt(100) * MIB);
|
||||
});
|
||||
|
||||
it.each(['0', '-104857600', 'abc'])(
|
||||
'falls back to 32 MiB rather than the 5 MiB floor for %s',
|
||||
(raw) => {
|
||||
vi.stubEnv('OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES', raw);
|
||||
expect(getR2MultipartPartSizeBytes()).toBe(BigInt(32) * MIB);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createHmac } from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
ensureGuestIdentityFromRequest,
|
||||
getGuestIdentityFromRequest,
|
||||
setGuestIdentityCookie,
|
||||
} from '@/lib/guest-identity';
|
||||
|
||||
const COOKIE_NAME = 'openframe_guest_identity';
|
||||
const TTL_SECONDS = 60 * 60 * 24 * 180;
|
||||
const SECRET = 'guest-identity-test-secret';
|
||||
const NOW = new Date('2026-01-15T00:00:00.000Z');
|
||||
|
||||
function issueCookieValue(identityId: string): string {
|
||||
const response = NextResponse.next();
|
||||
setGuestIdentityCookie(response, identityId);
|
||||
const value = response.cookies.get(COOKIE_NAME)?.value;
|
||||
if (!value) throw new Error('setGuestIdentityCookie did not write a cookie');
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestWithCookie(value: string | null): NextRequest {
|
||||
const headers = new Headers();
|
||||
if (value !== null) headers.set('cookie', `${COOKIE_NAME}=${value}`);
|
||||
return new NextRequest('https://example.com/share/abc', { headers });
|
||||
}
|
||||
|
||||
// Mirrors the production signing scheme so that deliberately malformed payloads
|
||||
// can be presented with a valid signature. There is no other way to reach the
|
||||
// payload validation branches from the outside.
|
||||
function signPayload(payloadJson: string, secret = SECRET): string {
|
||||
const encoded = Buffer.from(payloadJson, 'utf8').toString('base64url');
|
||||
const signature = createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.stubEnv('GUEST_IDENTITY_SECRET', SECRET);
|
||||
vi.stubEnv('AUTH_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('setGuestIdentityCookie and getGuestIdentityFromRequest', () => {
|
||||
it('round-trips an identity id through a signed cookie', () => {
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBe('guest-abc-123');
|
||||
});
|
||||
|
||||
it('returns null when the cookie is absent', () => {
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(null))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty cookie value', () => {
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(''))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the payload has been tampered with', () => {
|
||||
const [payload, signature] = issueCookieValue('guest-abc-123').split('.');
|
||||
const tampered = `${payload.slice(0, -1)}${payload.endsWith('A') ? 'B' : 'A'}.${signature}`;
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(tampered))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the signature has been tampered with', () => {
|
||||
const [payload, signature] = issueCookieValue('guest-abc-123').split('.');
|
||||
const tampered = `${payload}.${signature.slice(0, -1)}${signature.endsWith('A') ? 'B' : 'A'}`;
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(tampered))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the signature length does not match', () => {
|
||||
const [payload] = issueCookieValue('guest-abc-123').split('.');
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(`${payload}.short`))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the value carries no signature at all', () => {
|
||||
const [payload] = issueCookieValue('guest-abc-123').split('.');
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(payload))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the cookie was signed with a different secret', () => {
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
vi.stubEnv('GUEST_IDENTITY_SECRET', 'a-different-secret');
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the cookie one second before the 180 day expiry', () => {
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
vi.setSystemTime(new Date(NOW.getTime() + (TTL_SECONDS - 1) * 1000));
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBe('guest-abc-123');
|
||||
});
|
||||
|
||||
it('rejects the cookie at the exact expiry second', () => {
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
vi.setSystemTime(new Date(NOW.getTime() + TTL_SECONDS * 1000));
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the cookie well after expiry', () => {
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
vi.setSystemTime(new Date(NOW.getTime() + (TTL_SECONDS + 86_400) * 1000));
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a missing exp', '{"gid":"guest-1"}'],
|
||||
['exp as a string', '{"gid":"guest-1","exp":"9999999999"}'],
|
||||
['exp as NaN-producing null', '{"gid":"guest-1","exp":null}'],
|
||||
['a missing gid', '{"exp":9999999999}'],
|
||||
['gid as a number', '{"gid":42,"exp":9999999999}'],
|
||||
['an empty gid', '{"gid":"","exp":9999999999}'],
|
||||
['a non-object payload', '"just-a-string"'],
|
||||
['malformed JSON', '{"gid":'],
|
||||
])('returns null for a correctly signed payload with %s', (_label, payloadJson) => {
|
||||
const value = signPayload(payloadJson);
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a correctly signed payload with a far future exp', () => {
|
||||
const value = signPayload('{"gid":"guest-forever","exp":4102444800}');
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBe('guest-forever');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest identity secret resolution', () => {
|
||||
it('prefers GUEST_IDENTITY_SECRET over AUTH_SECRET', () => {
|
||||
vi.stubEnv('AUTH_SECRET', 'auth-secret');
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
|
||||
// Removing only the preferred secret must invalidate the cookie, which proves
|
||||
// it was the one used to sign.
|
||||
vi.stubEnv('GUEST_IDENTITY_SECRET', undefined);
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to AUTH_SECRET when GUEST_IDENTITY_SECRET is unset', () => {
|
||||
vi.stubEnv('GUEST_IDENTITY_SECRET', undefined);
|
||||
vi.stubEnv('AUTH_SECRET', 'auth-secret');
|
||||
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBe('guest-abc-123');
|
||||
});
|
||||
|
||||
it('falls back to NEXTAUTH_SECRET last', () => {
|
||||
vi.stubEnv('GUEST_IDENTITY_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', 'nextauth-secret');
|
||||
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
|
||||
expect(getGuestIdentityFromRequest(requestWithCookie(value))).toBe('guest-abc-123');
|
||||
});
|
||||
|
||||
it('throws when no secret is configured at all', () => {
|
||||
vi.stubEnv('GUEST_IDENTITY_SECRET', undefined);
|
||||
|
||||
expect(() => issueCookieValue('guest-abc-123')).toThrow(
|
||||
'Missing GUEST_IDENTITY_SECRET, AUTH_SECRET, or NEXTAUTH_SECRET.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest identity cookie attributes', () => {
|
||||
it('is http-only, lax and scoped to the whole site for 180 days', () => {
|
||||
const response = NextResponse.next();
|
||||
setGuestIdentityCookie(response, 'guest-abc-123');
|
||||
|
||||
const setCookie = response.headers.get('set-cookie') ?? '';
|
||||
|
||||
expect(setCookie).toContain('HttpOnly');
|
||||
expect(setCookie).toContain('Path=/');
|
||||
expect(setCookie.toLowerCase()).toContain('samesite=lax');
|
||||
expect(setCookie).toContain(`Max-Age=${TTL_SECONDS}`);
|
||||
});
|
||||
|
||||
it('is not marked Secure outside production', () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
const response = NextResponse.next();
|
||||
setGuestIdentityCookie(response, 'guest-abc-123');
|
||||
|
||||
expect(response.headers.get('set-cookie')).not.toContain('Secure');
|
||||
});
|
||||
|
||||
it('is marked Secure in production', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
const response = NextResponse.next();
|
||||
setGuestIdentityCookie(response, 'guest-abc-123');
|
||||
|
||||
expect(response.headers.get('set-cookie')).toContain('Secure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureGuestIdentityFromRequest', () => {
|
||||
it('reuses an existing identity without asking for a new cookie', () => {
|
||||
const value = issueCookieValue('guest-abc-123');
|
||||
|
||||
expect(ensureGuestIdentityFromRequest(requestWithCookie(value))).toEqual({
|
||||
identityId: 'guest-abc-123',
|
||||
shouldSetCookie: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('mints a uuid identity and asks for a cookie when none is present', () => {
|
||||
const result = ensureGuestIdentityFromRequest(requestWithCookie(null));
|
||||
|
||||
expect(result.shouldSetCookie).toBe(true);
|
||||
expect(result.identityId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
|
||||
it('mints a fresh identity for an invalid cookie rather than reusing the payload', () => {
|
||||
const [payload] = issueCookieValue('guest-abc-123').split('.');
|
||||
const result = ensureGuestIdentityFromRequest(requestWithCookie(`${payload}.forged`));
|
||||
|
||||
expect(result.shouldSetCookie).toBe(true);
|
||||
expect(result.identityId).not.toBe('guest-abc-123');
|
||||
});
|
||||
|
||||
it('does not reuse the same identity across two anonymous requests', () => {
|
||||
const first = ensureGuestIdentityFromRequest(requestWithCookie(null));
|
||||
const second = ensureGuestIdentityFromRequest(requestWithCookie(null));
|
||||
|
||||
expect(first.identityId).not.toBe(second.identityId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bigIntReplacer, toJsonSafe } from '@/lib/json-serialize';
|
||||
|
||||
describe('bigIntReplacer', () => {
|
||||
it.each([
|
||||
[BigInt(0), '0'],
|
||||
[BigInt(1), '1'],
|
||||
[BigInt(-5), '-5'],
|
||||
[BigInt('9007199254740993'), '9007199254740993'],
|
||||
[BigInt('-9007199254740993'), '-9007199254740993'],
|
||||
])('renders the BigInt %s as the string %s', (value, expected) => {
|
||||
expect(bigIntReplacer('sizeBytes', value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a number', 42],
|
||||
['a string', 'hello'],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['a boolean', false],
|
||||
])('passes %s through unchanged', (_label, value) => {
|
||||
expect(bigIntReplacer('key', value)).toBe(value);
|
||||
});
|
||||
|
||||
it('passes an object through by reference so JSON.stringify can keep walking it', () => {
|
||||
const nested = { a: 1 };
|
||||
expect(bigIntReplacer('key', nested)).toBe(nested);
|
||||
});
|
||||
|
||||
it('ignores the key argument entirely', () => {
|
||||
expect(bigIntReplacer('', BigInt(7))).toBe('7');
|
||||
expect(bigIntReplacer('anything', BigInt(7))).toBe('7');
|
||||
});
|
||||
|
||||
it('lets JSON.stringify serialise a payload that would otherwise throw', () => {
|
||||
const payload = { sizeBytes: BigInt(1024) };
|
||||
|
||||
expect(() => JSON.stringify(payload)).toThrow(TypeError);
|
||||
expect(JSON.stringify(payload, bigIntReplacer)).toBe('{"sizeBytes":"1024"}');
|
||||
});
|
||||
|
||||
it('converts BigInt values nested in objects and arrays', () => {
|
||||
const payload = {
|
||||
versions: [{ sizeBytes: BigInt(0) }, { sizeBytes: BigInt(-1) }],
|
||||
quota: { used: BigInt(2048), nested: { deep: BigInt(3) } },
|
||||
};
|
||||
|
||||
expect(JSON.parse(JSON.stringify(payload, bigIntReplacer))).toEqual({
|
||||
versions: [{ sizeBytes: '0' }, { sizeBytes: '-1' }],
|
||||
quota: { used: '2048', nested: { deep: '3' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('converts a bare BigInt at the root of the payload', () => {
|
||||
expect(JSON.stringify(BigInt(9), bigIntReplacer)).toBe('"9"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJsonSafe', () => {
|
||||
it('replaces BigInt values with strings across a nested structure', () => {
|
||||
const input = {
|
||||
id: 'v1',
|
||||
sizeBytes: BigInt('5368709120'),
|
||||
assets: [{ sizeBytes: BigInt(0) }],
|
||||
};
|
||||
|
||||
expect(toJsonSafe(input)).toEqual({
|
||||
id: 'v1',
|
||||
sizeBytes: '5368709120',
|
||||
assets: [{ sizeBytes: '0' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a detached copy rather than the input object', () => {
|
||||
const input = { nested: { count: 1 } };
|
||||
const output = toJsonSafe(input);
|
||||
|
||||
expect(output).not.toBe(input);
|
||||
expect(output.nested).not.toBe(input.nested);
|
||||
});
|
||||
|
||||
it('turns a Date into its ISO string, matching JSON.stringify', () => {
|
||||
const output = toJsonSafe({ createdAt: new Date('2026-01-15T00:00:00.000Z') });
|
||||
|
||||
expect(output.createdAt).toEqual('2026-01-15T00:00:00.000Z' as unknown as Date);
|
||||
});
|
||||
|
||||
it('drops properties whose value is undefined', () => {
|
||||
const output = toJsonSafe({ a: 1, b: undefined }) as Record<string, unknown>;
|
||||
|
||||
expect('b' in output).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves null but not undefined inside arrays', () => {
|
||||
expect(toJsonSafe([null, undefined, BigInt(1)])).toEqual([null, null, '1']);
|
||||
});
|
||||
|
||||
it('handles a value with no BigInt at all', () => {
|
||||
expect(toJsonSafe({ a: 1, b: 'two', c: [true, null] })).toEqual({
|
||||
a: 1,
|
||||
b: 'two',
|
||||
c: [true, null],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a circular structure, as JSON.stringify does', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
|
||||
expect(() => toJsonSafe(circular)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,592 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
RATE_LIMIT_CONFIGS,
|
||||
checkRateLimit,
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitHeaders,
|
||||
} from '@/lib/rate-limit';
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
$queryRaw: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
|
||||
|
||||
function requestWith(headers: Record<string, string>): Request {
|
||||
return new Request('https://example.com/api/comments', { headers });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TRUSTED_PROXY_MODE', undefined);
|
||||
vi.stubEnv('DISABLE_RATE_LIMIT', undefined);
|
||||
dbMock.$queryRaw.mockReset();
|
||||
dbMock.$executeRaw.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('getClientIp without TRUSTED_PROXY_MODE', () => {
|
||||
it('ignores every proxy header and returns the loopback address', () => {
|
||||
const request = requestWith({
|
||||
'cf-connecting-ip': '203.0.113.7',
|
||||
'x-real-ip': '203.0.113.8',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
});
|
||||
|
||||
expect(getClientIp(request)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('returns the loopback address when no headers are present at all', () => {
|
||||
expect(getClientIp(requestWith({}))).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('ignores an unrecognised proxy mode', () => {
|
||||
vi.stubEnv('TRUSTED_PROXY_MODE', 'apache');
|
||||
expect(getClientIp(requestWith({ 'x-real-ip': '203.0.113.8' }))).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientIp in cloudflare mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TRUSTED_PROXY_MODE', 'cloudflare');
|
||||
});
|
||||
|
||||
it('trusts cf-connecting-ip', () => {
|
||||
expect(getClientIp(requestWith({ 'cf-connecting-ip': '203.0.113.7' }))).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('normalises a padded and mixed-case mode value', () => {
|
||||
vi.stubEnv('TRUSTED_PROXY_MODE', ' CloudFlare ');
|
||||
expect(getClientIp(requestWith({ 'cf-connecting-ip': '203.0.113.7' }))).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('does not fall back to x-forwarded-for, which a client can set', () => {
|
||||
const request = requestWith({ 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.8' });
|
||||
expect(getClientIp(request)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('rejects an implausible cf-connecting-ip rather than trusting it', () => {
|
||||
expect(getClientIp(requestWith({ 'cf-connecting-ip': 'not-an-ip' }))).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('rejects a cf-connecting-ip longer than 45 characters', () => {
|
||||
const tooLong = '1'.repeat(46);
|
||||
expect(getClientIp(requestWith({ 'cf-connecting-ip': tooLong }))).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('accepts an IPv6 address', () => {
|
||||
expect(getClientIp(requestWith({ 'cf-connecting-ip': '2001:db8::1' }))).toBe('2001:db8::1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientIp in nginx mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TRUSTED_PROXY_MODE', 'nginx');
|
||||
});
|
||||
|
||||
it('prefers x-real-ip over x-forwarded-for', () => {
|
||||
const request = requestWith({ 'x-real-ip': '203.0.113.8', 'x-forwarded-for': '203.0.113.9' });
|
||||
expect(getClientIp(request)).toBe('203.0.113.8');
|
||||
});
|
||||
|
||||
it('takes the last x-forwarded-for entry so a spoofed prefix is ignored', () => {
|
||||
const request = requestWith({ 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 203.0.113.9' });
|
||||
expect(getClientIp(request)).toBe('203.0.113.9');
|
||||
});
|
||||
|
||||
it('trims whitespace around the last x-forwarded-for entry', () => {
|
||||
expect(getClientIp(requestWith({ 'x-forwarded-for': '1.1.1.1, 203.0.113.9 ' }))).toBe(
|
||||
'203.0.113.9'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles a single-entry x-forwarded-for', () => {
|
||||
expect(getClientIp(requestWith({ 'x-forwarded-for': '203.0.113.9' }))).toBe('203.0.113.9');
|
||||
});
|
||||
|
||||
it('falls back to x-forwarded-for when x-real-ip is implausible', () => {
|
||||
const request = requestWith({
|
||||
'x-real-ip': 'evil<script>',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
});
|
||||
expect(getClientIp(request)).toBe('203.0.113.9');
|
||||
});
|
||||
|
||||
it('returns the loopback address when the last x-forwarded-for entry is implausible', () => {
|
||||
expect(getClientIp(requestWith({ 'x-forwarded-for': '203.0.113.9, bogus-host' }))).toBe(
|
||||
'127.0.0.1'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an x-real-ip carrying a SQL fragment', () => {
|
||||
const request = requestWith({ 'x-real-ip': "1.2.3.4'; DROP TABLE rate_limits; --" });
|
||||
expect(getClientIp(request)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('accepts a bare hex string because IP_PATTERN is only a loose shape check', () => {
|
||||
// Documents the deliberate looseness: the pattern guards the length and the
|
||||
// character set, it is not a full IP parser.
|
||||
expect(getClientIp(requestWith({ 'x-real-ip': 'dead' }))).toBe('dead');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rateLimitHeaders', () => {
|
||||
it('renders the limit, the remaining count and the reset as unix seconds', () => {
|
||||
const result = {
|
||||
allowed: true,
|
||||
remaining: 7,
|
||||
resetAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
expect(rateLimitHeaders(result, 15)).toEqual({
|
||||
'X-RateLimit-Limit': '15',
|
||||
'X-RateLimit-Remaining': '7',
|
||||
'X-RateLimit-Reset': '1768435200',
|
||||
});
|
||||
});
|
||||
|
||||
it('floors sub-second precision on the reset timestamp', () => {
|
||||
const result = {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date('2026-01-15T00:00:00.999Z'),
|
||||
};
|
||||
|
||||
const headers = rateLimitHeaders(result, 1) as Record<string, string>;
|
||||
|
||||
expect(headers['X-RateLimit-Reset']).toBe('1768435200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RATE_LIMIT_CONFIGS', () => {
|
||||
const entries = Object.entries(RATE_LIMIT_CONFIGS);
|
||||
|
||||
it('is not empty', () => {
|
||||
expect(entries.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it.each(entries)('%s has a positive window and a positive request cap', (_action, config) => {
|
||||
expect(config.windowMs).toBeGreaterThan(0);
|
||||
expect(config.maxRequests).toBeGreaterThan(0);
|
||||
expect(Number.isInteger(config.maxRequests)).toBe(true);
|
||||
expect(config.windowMs % 1000).toBe(0);
|
||||
});
|
||||
|
||||
it('defines the api fallback that unknown actions resolve to', () => {
|
||||
expect(RATE_LIMIT_CONFIGS.api).toEqual({ windowMs: 60_000, maxRequests: 100 });
|
||||
});
|
||||
|
||||
it('keeps auth actions stricter per minute than the general api bucket', () => {
|
||||
const perMinute = (action: string) =>
|
||||
(RATE_LIMIT_CONFIGS[action].maxRequests / RATE_LIMIT_CONFIGS[action].windowMs) * 60_000;
|
||||
|
||||
expect(perMinute('login')).toBeLessThan(perMinute('api'));
|
||||
expect(perMinute('register')).toBeLessThan(perMinute('login'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
const windowStart = new Date('2026-01-15T00:00:00.000Z');
|
||||
|
||||
function rowsWithCount(count: number) {
|
||||
return [{ count, window_start: windowStart, is_new_window: false }];
|
||||
}
|
||||
|
||||
it('allows without querying the database when DISABLE_RATE_LIMIT is set', async () => {
|
||||
vi.stubEnv('DISABLE_RATE_LIMIT', '1');
|
||||
|
||||
const result = await checkRateLimit('1.2.3.4', 'comment');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(RATE_LIMIT_CONFIGS.comment.maxRequests);
|
||||
expect(dbMock.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['1', 'true', 'YES', ' on '])('treats DISABLE_RATE_LIMIT=%s as disabled', async (raw) => {
|
||||
vi.stubEnv('DISABLE_RATE_LIMIT', raw);
|
||||
await checkRateLimit('1.2.3.4', 'comment');
|
||||
expect(dbMock.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still enforces the limit for a non-truthy DISABLE_RATE_LIMIT value', async () => {
|
||||
vi.stubEnv('DISABLE_RATE_LIMIT', 'false');
|
||||
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
|
||||
|
||||
await checkRateLimit('1.2.3.4', 'comment');
|
||||
|
||||
expect(dbMock.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips the query for an over-long key', async () => {
|
||||
const result = await checkRateLimit('k'.repeat(257), 'comment');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(dbMock.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queries for a key at exactly the 256 character limit', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
|
||||
|
||||
await checkRateLimit('k'.repeat(256), 'comment');
|
||||
|
||||
expect(dbMock.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips the query for an over-long action', async () => {
|
||||
await checkRateLimit('1.2.3.4', 'a'.repeat(65));
|
||||
expect(dbMock.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the remaining budget and the reset instant from the stored window', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(4));
|
||||
|
||||
const result = await checkRateLimit('1.2.3.4', 'comment');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(RATE_LIMIT_CONFIGS.comment.maxRequests - 4);
|
||||
expect(result.resetAt.toISOString()).toBe('2026-01-15T00:01:00.000Z');
|
||||
});
|
||||
|
||||
it('still allows the request that lands exactly on the cap', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(RATE_LIMIT_CONFIGS.comment.maxRequests));
|
||||
|
||||
const result = await checkRateLimit('1.2.3.4', 'comment');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks the first request past the cap and never reports a negative remainder', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(RATE_LIMIT_CONFIGS.comment.maxRequests + 3));
|
||||
|
||||
const result = await checkRateLimit('1.2.3.4', 'comment');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to the api bucket for an unknown action', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
|
||||
|
||||
const result = await checkRateLimit('1.2.3.4', 'action-that-does-not-exist');
|
||||
|
||||
expect(result.remaining).toBe(RATE_LIMIT_CONFIGS.api.maxRequests - 1);
|
||||
});
|
||||
|
||||
it('prefers an explicitly supplied config over the table', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(2));
|
||||
|
||||
const result = await checkRateLimit('1.2.3.4', 'comment', {
|
||||
windowMs: 10_000,
|
||||
maxRequests: 3,
|
||||
});
|
||||
|
||||
expect(result.remaining).toBe(1);
|
||||
expect(result.resetAt.toISOString()).toBe('2026-01-15T00:00:10.000Z');
|
||||
});
|
||||
|
||||
it('fails open when the rate limit table query throws', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
dbMock.$queryRaw.mockRejectedValue(new Error('relation "rate_limits" does not exist'));
|
||||
|
||||
const result = await checkRateLimit('1.2.3.4', 'comment');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(RATE_LIMIT_CONFIGS.comment.maxRequests);
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rateLimit', () => {
|
||||
it('returns null while the caller is under the cap', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue([
|
||||
{ count: 1, window_start: new Date('2026-01-15T00:00:00.000Z'), is_new_window: true },
|
||||
]);
|
||||
|
||||
expect(await rateLimit(requestWith({}), 'comment')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a 429 with rate limit headers once the cap is passed', async () => {
|
||||
dbMock.$queryRaw.mockResolvedValue([
|
||||
{ count: 99, window_start: new Date('2026-01-15T00:00:00.000Z'), is_new_window: false },
|
||||
]);
|
||||
|
||||
const response = await rateLimit(requestWith({}), 'comment');
|
||||
|
||||
expect(response?.status).toBe(429);
|
||||
expect(response?.headers.get('X-RateLimit-Limit')).toBe(
|
||||
String(RATE_LIMIT_CONFIGS.comment.maxRequests)
|
||||
);
|
||||
expect(response?.headers.get('X-RateLimit-Remaining')).toBe('0');
|
||||
await expect(response?.json()).resolves.toEqual({
|
||||
error: 'Too many requests. Please try again later.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keys the limit on the resolved client ip', async () => {
|
||||
vi.stubEnv('TRUSTED_PROXY_MODE', 'nginx');
|
||||
dbMock.$queryRaw.mockResolvedValue([
|
||||
{ count: 1, window_start: new Date('2026-01-15T00:00:00.000Z'), is_new_window: true },
|
||||
]);
|
||||
|
||||
await rateLimit(requestWith({ 'x-real-ip': '203.0.113.8' }), 'comment');
|
||||
|
||||
const values = dbMock.$queryRaw.mock.calls[0].slice(1);
|
||||
expect(values).toContain('203.0.113.8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getSiteUrl, seoConfig } from '@/lib/seo';
|
||||
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
|
||||
|
||||
const FALLBACK = 'https://open-frame.net';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('getSiteUrl', () => {
|
||||
it('falls back to the production origin when neither variable is set', () => {
|
||||
expect(getSiteUrl()).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('prefers NEXT_PUBLIC_APP_URL over NEXTAUTH_URL', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com');
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://auth.example.com');
|
||||
|
||||
expect(getSiteUrl()).toBe('https://app.example.com');
|
||||
});
|
||||
|
||||
it('uses NEXTAUTH_URL when the public variable is unset', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://auth.example.com');
|
||||
|
||||
expect(getSiteUrl()).toBe('https://auth.example.com');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://example.com/marketing/pricing', 'https://example.com'],
|
||||
['https://example.com?utm=x', 'https://example.com'],
|
||||
['https://example.com:8443/path', 'https://example.com:8443'],
|
||||
['http://localhost:3000/', 'http://localhost:3000'],
|
||||
])('reduces %s to the origin %s', (raw, expected) => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', raw);
|
||||
|
||||
expect(getSiteUrl()).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['example.com', 'https://example.com'],
|
||||
['example.com/path', 'https://example.com'],
|
||||
[' example.com ', 'https://example.com'],
|
||||
])('adds the https scheme to %s', (raw, expected) => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', raw);
|
||||
|
||||
expect(getSiteUrl()).toBe(expected);
|
||||
});
|
||||
|
||||
it('keeps an explicit http scheme rather than upgrading it', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://example.com');
|
||||
|
||||
expect(getSiteUrl()).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['only whitespace', ' '],
|
||||
['a bare colon', ':'],
|
||||
])('falls back for %s', (_label, raw) => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', raw);
|
||||
|
||||
expect(getSiteUrl()).toBe(FALLBACK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seoConfig', () => {
|
||||
it('resolves its url once at import time and does not follow later env changes', () => {
|
||||
const atImport = seoConfig.url;
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://changed.example.com');
|
||||
|
||||
expect(seoConfig.url).toBe(atImport);
|
||||
expect(getSiteUrl()).toBe('https://changed.example.com');
|
||||
});
|
||||
|
||||
it('exposes an origin with no trailing slash so paths can be appended directly', () => {
|
||||
expect(seoConfig.url).toMatch(/^https?:\/\/[^/]+$/);
|
||||
});
|
||||
|
||||
it('keeps the description within the length search engines render', () => {
|
||||
expect(seoConfig.description.length).toBeGreaterThan(50);
|
||||
expect(seoConfig.description.length).toBeLessThanOrEqual(160);
|
||||
});
|
||||
|
||||
it('keeps the title short enough to avoid truncation', () => {
|
||||
expect(seoConfig.title.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('has no duplicate keywords', () => {
|
||||
expect(new Set(seoConfig.keywords).size).toBe(seoConfig.keywords.length);
|
||||
});
|
||||
|
||||
it('points the og image and logo at site-relative paths', () => {
|
||||
expect(seoConfig.ogImage.startsWith('/')).toBe(true);
|
||||
expect(seoConfig.logo.startsWith('/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComparisonMetadata', () => {
|
||||
const input = {
|
||||
title: 'OpenFrame vs Frame.io',
|
||||
description: 'A side by side comparison of OpenFrame and Frame.io for video review.',
|
||||
path: 'compare/frameio',
|
||||
keywords: ['frame.io alternative'],
|
||||
};
|
||||
|
||||
it('adds a leading slash to a relative canonical path', () => {
|
||||
expect(buildComparisonMetadata(input).alternates?.canonical).toBe('/compare/frameio');
|
||||
});
|
||||
|
||||
it('leaves an already absolute path alone', () => {
|
||||
expect(
|
||||
buildComparisonMetadata({ ...input, path: '/compare/frameio' }).alternates?.canonical
|
||||
).toBe('/compare/frameio');
|
||||
});
|
||||
|
||||
it('keeps the page title unbranded and brands only the social titles', () => {
|
||||
const metadata = buildComparisonMetadata(input);
|
||||
|
||||
expect(metadata.title).toBe('OpenFrame vs Frame.io');
|
||||
expect(metadata.openGraph?.title).toBe('OpenFrame vs Frame.io | OpenFrame');
|
||||
expect(metadata.twitter?.title).toBe('OpenFrame vs Frame.io | OpenFrame');
|
||||
});
|
||||
|
||||
it('builds an absolute open graph url from the site origin and the canonical path', () => {
|
||||
expect(buildComparisonMetadata(input).openGraph?.url).toBe(`${seoConfig.url}/compare/frameio`);
|
||||
});
|
||||
|
||||
it('appends the page keywords after the shared ones', () => {
|
||||
const keywords = buildComparisonMetadata(input).keywords as string[];
|
||||
|
||||
expect(keywords.slice(0, seoConfig.keywords.length)).toEqual([...seoConfig.keywords]);
|
||||
expect(keywords[keywords.length - 1]).toBe('frame.io alternative');
|
||||
});
|
||||
|
||||
it('defaults to the shared keywords when none are supplied', () => {
|
||||
const keywords = buildComparisonMetadata({ ...input, keywords: undefined })
|
||||
.keywords as string[];
|
||||
|
||||
expect(keywords).toEqual([...seoConfig.keywords]);
|
||||
});
|
||||
|
||||
it('declares a large summary card with the shared og image', () => {
|
||||
const metadata = buildComparisonMetadata(input);
|
||||
|
||||
// The Twitter metadata type is a union whose `card` field is only present on
|
||||
// some branches, so read it through a narrow structural view.
|
||||
const twitter = metadata.twitter as { card?: string; images?: unknown };
|
||||
expect(twitter.card).toBe('summary_large_image');
|
||||
expect(twitter.images).toEqual([seoConfig.ogImage]);
|
||||
expect(metadata.openGraph?.images).toEqual([
|
||||
{
|
||||
url: seoConfig.ogImage,
|
||||
width: 1888,
|
||||
height: 1048,
|
||||
alt: 'OpenFrame vs Frame.io | OpenFrame',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses the same description across the page, open graph and twitter blocks', () => {
|
||||
const metadata = buildComparisonMetadata(input);
|
||||
|
||||
expect(metadata.description).toBe(input.description);
|
||||
expect(metadata.openGraph?.description).toBe(input.description);
|
||||
expect(metadata.twitter?.description).toBe(input.description);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComparisonJsonLd', () => {
|
||||
const input = {
|
||||
title: 'OpenFrame vs Frame.io',
|
||||
description: 'A comparison.',
|
||||
path: 'compare/frameio',
|
||||
faq: [],
|
||||
};
|
||||
|
||||
it('emits a WebPage and a SoftwareApplication node when there is no FAQ', () => {
|
||||
const nodes = buildComparisonJsonLd(input);
|
||||
|
||||
expect(nodes.map((node) => node['@type'])).toEqual(['WebPage', 'SoftwareApplication']);
|
||||
});
|
||||
|
||||
it('builds an absolute page url and normalises a relative path', () => {
|
||||
const [webPage] = buildComparisonJsonLd(input);
|
||||
|
||||
expect(webPage.url).toBe(`${seoConfig.url}/compare/frameio`);
|
||||
});
|
||||
|
||||
it('does not double the leading slash on an absolute path', () => {
|
||||
const [webPage] = buildComparisonJsonLd({ ...input, path: '/compare/frameio' });
|
||||
|
||||
expect(webPage.url).toBe(`${seoConfig.url}/compare/frameio`);
|
||||
});
|
||||
|
||||
it('links the page to the site via isPartOf', () => {
|
||||
const [webPage] = buildComparisonJsonLd(input);
|
||||
|
||||
expect(webPage.isPartOf).toEqual({
|
||||
'@type': 'WebSite',
|
||||
name: 'OpenFrame',
|
||||
url: seoConfig.url,
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the hosted price on the SoftwareApplication node', () => {
|
||||
const [, app] = buildComparisonJsonLd(input);
|
||||
|
||||
expect(app.offers).toMatchObject({ '@type': 'Offer', price: '10', priceCurrency: 'USD' });
|
||||
expect(app.applicationCategory).toBe('MultimediaApplication');
|
||||
});
|
||||
|
||||
it('appends an FAQPage node built from the questions', () => {
|
||||
const nodes = buildComparisonJsonLd({
|
||||
...input,
|
||||
faq: [
|
||||
{ question: 'Is it open source?', answer: 'It is fair source.' },
|
||||
{ question: 'Is there a trial?', answer: 'Seven days.' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes[2]).toEqual({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Is it open source?',
|
||||
acceptedAnswer: { '@type': 'Answer', text: 'It is fair source.' },
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Is there a trial?',
|
||||
acceptedAnswer: { '@type': 'Answer', text: 'Seven days.' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the schema.org context on every node', () => {
|
||||
const nodes = buildComparisonJsonLd({
|
||||
...input,
|
||||
faq: [{ question: 'Q', answer: 'A' }],
|
||||
});
|
||||
|
||||
expect(nodes.every((node) => node['@context'] === 'https://schema.org')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { MAX_SHARE_PASSWORD_LENGTH, validateShareLinkAccess } from '@/lib/share-links';
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
shareLink: { findUnique: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
|
||||
|
||||
const NOW = new Date('2026-01-15T00:00:00.000Z');
|
||||
const PASSWORD = 'correct horse battery staple';
|
||||
// Cost 4 keeps the suite fast; the comparison logic is identical at any cost.
|
||||
const PASSWORD_HASH = bcrypt.hashSync(PASSWORD, 4);
|
||||
const LONG_PASSWORD = 'a'.repeat(MAX_SHARE_PASSWORD_LENGTH + 1);
|
||||
const LONG_PASSWORD_HASH = bcrypt.hashSync(LONG_PASSWORD, 4);
|
||||
|
||||
type OwnerBilling = {
|
||||
subscriptionStatus: BillingSubscriptionStatus;
|
||||
trialEndsAt: Date | null;
|
||||
stripeCurrentPeriodEnd: Date | null;
|
||||
billingAccessEndedAt: Date | null;
|
||||
};
|
||||
|
||||
const ACTIVE_OWNER: OwnerBilling = {
|
||||
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
|
||||
trialEndsAt: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
};
|
||||
|
||||
const EXPIRED_OWNER: OwnerBilling = {
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
trialEndsAt: new Date('2025-12-01T00:00:00.000Z'),
|
||||
stripeCurrentPeriodEnd: new Date('2025-12-01T00:00:00.000Z'),
|
||||
billingAccessEndedAt: new Date('2025-12-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
type LinkOverrides = {
|
||||
projectId?: string;
|
||||
videoId?: string | null;
|
||||
permission?: 'VIEW' | 'COMMENT';
|
||||
expiresAt?: Date | null;
|
||||
passwordHash?: string | null;
|
||||
allowGuests?: boolean;
|
||||
allowDownloads?: boolean;
|
||||
owner?: OwnerBilling | null;
|
||||
project?: unknown;
|
||||
};
|
||||
|
||||
function mockLink(overrides: LinkOverrides = {}) {
|
||||
const { owner = ACTIVE_OWNER, project, ...rest } = overrides;
|
||||
|
||||
dbMock.shareLink.findUnique.mockResolvedValue({
|
||||
id: 'link-1',
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
videoId: null,
|
||||
permission: 'VIEW',
|
||||
expiresAt: null,
|
||||
passwordHash: null,
|
||||
allowGuests: true,
|
||||
allowDownloads: false,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
project: project !== undefined ? project : { workspace: { owner } },
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
|
||||
dbMock.shareLink.findUnique.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('validateShareLinkAccess', () => {
|
||||
it('denies access and returns no link for an unknown token', async () => {
|
||||
dbMock.shareLink.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
validateShareLinkAccess({ token: 'nope', projectId: 'project-1' })
|
||||
).resolves.toEqual({
|
||||
hasAccess: false,
|
||||
canComment: false,
|
||||
canDownload: false,
|
||||
allowGuests: false,
|
||||
requiresPassword: false,
|
||||
link: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('grants read access for a matching project-scoped VIEW link', async () => {
|
||||
mockLink();
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.hasAccess).toBe(true);
|
||||
expect(result.canComment).toBe(false);
|
||||
expect(result.canDownload).toBe(false);
|
||||
expect(result.allowGuests).toBe(true);
|
||||
expect(result.requiresPassword).toBe(false);
|
||||
});
|
||||
|
||||
it('denies access when the link belongs to a different project', async () => {
|
||||
mockLink({ projectId: 'project-other' });
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('denies a project-wide request when the link is scoped to a single video', async () => {
|
||||
mockLink({ videoId: 'video-1' });
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('grants access when the requested video matches the link scope', async () => {
|
||||
mockLink({ videoId: 'video-1' });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
videoId: 'video-1',
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('denies a video request against a project-wide link', async () => {
|
||||
mockLink({ videoId: null });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
videoId: 'video-1',
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('denies a video request against a link scoped to another video', async () => {
|
||||
mockLink({ videoId: 'video-2' });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
videoId: 'video-1',
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['VIEW', 'VIEW', true, false],
|
||||
['COMMENT', 'VIEW', true, true],
|
||||
['VIEW', 'COMMENT', false, false],
|
||||
['COMMENT', 'COMMENT', true, true],
|
||||
] as const)(
|
||||
'a %s link asked for %s permission grants access=%s and comment=%s',
|
||||
async (linkPermission, required, expectedAccess, expectedComment) => {
|
||||
mockLink({ permission: linkPermission });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
requiredPermission: required,
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(expectedAccess);
|
||||
expect(result.canComment).toBe(expectedComment);
|
||||
}
|
||||
);
|
||||
|
||||
it('defaults the required permission to VIEW', async () => {
|
||||
mockLink({ permission: 'VIEW' });
|
||||
|
||||
expect(
|
||||
(await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' })).hasAccess
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a link with no expiry as permanent', async () => {
|
||||
mockLink({ expiresAt: null });
|
||||
|
||||
expect(
|
||||
(await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' })).hasAccess
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants access one millisecond before expiry', async () => {
|
||||
mockLink({ expiresAt: new Date(NOW.getTime() + 1) });
|
||||
|
||||
expect(
|
||||
(await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' })).hasAccess
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies access at the exact expiry instant', async () => {
|
||||
mockLink({ expiresAt: new Date(NOW.getTime()) });
|
||||
|
||||
expect(
|
||||
(await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' })).hasAccess
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies access after expiry', async () => {
|
||||
mockLink({ expiresAt: new Date(NOW.getTime() - 1000) });
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.requiresPassword).toBe(false);
|
||||
});
|
||||
|
||||
it('denies access when the workspace owner has lost billing access', async () => {
|
||||
mockLink({ owner: EXPIRED_OWNER });
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.canDownload).toBe(false);
|
||||
});
|
||||
|
||||
it('grants access to an expired-billing workspace when Stripe is disabled', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||
mockLink({ owner: EXPIRED_OWNER });
|
||||
|
||||
expect(
|
||||
(await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' })).hasAccess
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies access when the workspace owner row is missing', async () => {
|
||||
mockLink({ owner: null });
|
||||
|
||||
expect(
|
||||
(await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' })).hasAccess
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies access when the included project relation is missing', async () => {
|
||||
mockLink({ project: null });
|
||||
|
||||
expect(
|
||||
(await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' })).hasAccess
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('asks for a password when the link is protected and none was presented', async () => {
|
||||
mockLink({ passwordHash: PASSWORD_HASH });
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.requiresPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong password and keeps asking', async () => {
|
||||
mockLink({ passwordHash: PASSWORD_HASH });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
presentedPassword: 'wrong password',
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.requiresPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the correct password', async () => {
|
||||
mockLink({ passwordHash: PASSWORD_HASH, permission: 'COMMENT', allowDownloads: true });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
requiredPermission: 'COMMENT',
|
||||
presentedPassword: PASSWORD,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
hasAccess: true,
|
||||
canComment: true,
|
||||
canDownload: true,
|
||||
requiresPassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a correct but over-long password before hashing it', async () => {
|
||||
mockLink({ passwordHash: LONG_PASSWORD_HASH });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
presentedPassword: LONG_PASSWORD,
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.requiresPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the password check when the session already verified it', async () => {
|
||||
mockLink({ passwordHash: PASSWORD_HASH });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
passwordVerified: true,
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(true);
|
||||
expect(result.requiresPassword).toBe(false);
|
||||
});
|
||||
|
||||
it('does not ask for a password on an unprotected link even when one is presented', async () => {
|
||||
mockLink({ passwordHash: null });
|
||||
|
||||
const result = await validateShareLinkAccess({
|
||||
token: 'tok_abc',
|
||||
projectId: 'project-1',
|
||||
presentedPassword: 'anything',
|
||||
});
|
||||
|
||||
expect(result.hasAccess).toBe(true);
|
||||
expect(result.requiresPassword).toBe(false);
|
||||
});
|
||||
|
||||
it('reports allowGuests and allowDownloads straight from the link row', async () => {
|
||||
mockLink({ allowGuests: false, allowDownloads: true });
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.allowGuests).toBe(false);
|
||||
expect(result.canDownload).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses allowGuests and allowDownloads on every denial path', async () => {
|
||||
mockLink({ allowGuests: true, allowDownloads: true, expiresAt: new Date(NOW.getTime() - 1) });
|
||||
|
||||
const result = await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(result.allowGuests).toBe(false);
|
||||
expect(result.canDownload).toBe(false);
|
||||
});
|
||||
|
||||
it('looks the link up by token alone', async () => {
|
||||
mockLink();
|
||||
|
||||
await validateShareLinkAccess({ token: 'tok_abc', projectId: 'project-1' });
|
||||
|
||||
expect(dbMock.shareLink.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { token: 'tok_abc' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
detectImageMime,
|
||||
firstBytesHex,
|
||||
getImageExtension,
|
||||
isAllowedImageType,
|
||||
normalizeImageMime,
|
||||
} from '@/lib/image-upload-validation';
|
||||
import {
|
||||
VIDEO_OBJECT_KEY_PREFIX,
|
||||
VIDEO_PROXY_PREFIX,
|
||||
buildVideoObjectKey,
|
||||
getVideoExtensionFromFileName,
|
||||
getVideoExtensionFromMime,
|
||||
isAllowedVideoFile,
|
||||
isPlayableVideoUrl,
|
||||
normalizeVideoMime,
|
||||
objectKeyToVideoProxyPath,
|
||||
resolveR2PlaybackUrl,
|
||||
resolveVideoContentType,
|
||||
videoProxyPathFromFilename,
|
||||
videoProxyPathToObjectKey,
|
||||
} from '@/lib/video-upload-validation';
|
||||
|
||||
const UUID = '3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
|
||||
describe('normalizeVideoMime', () => {
|
||||
it.each([
|
||||
['video/mp4', 'video/mp4'],
|
||||
['VIDEO/MP4', 'video/mp4'],
|
||||
['video/mp4; codecs="avc1.42E01E"', 'video/mp4'],
|
||||
[' video/webm ', 'video/webm'],
|
||||
['video/x-matroska', 'video/x-matroska'],
|
||||
])('normalises %s to %s', (input, expected) => {
|
||||
expect(normalizeVideoMime(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['undefined', undefined],
|
||||
['an empty string', ''],
|
||||
['image/png', 'image/png'],
|
||||
['application/octet-stream', 'application/octet-stream'],
|
||||
['text/html;video/mp4', 'text/html;video/mp4'],
|
||||
])('returns null for %s', (_label, input) => {
|
||||
expect(normalizeVideoMime(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVideoExtensionFromMime', () => {
|
||||
it.each([
|
||||
['video/mp4', 'mp4'],
|
||||
['video/webm', 'webm'],
|
||||
['video/ogg', 'ogg'],
|
||||
['video/quicktime', 'mov'],
|
||||
['video/x-matroska', 'mkv'],
|
||||
['video/x-msvideo', 'avi'],
|
||||
])('maps %s to %s', (mime, expected) => {
|
||||
expect(getVideoExtensionFromMime(mime)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns null for an unmapped video mime', () => {
|
||||
expect(getVideoExtensionFromMime('video/3gpp')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVideoExtensionFromFileName', () => {
|
||||
it.each([
|
||||
['clip.mp4', 'mp4'],
|
||||
['clip.MP4', 'mp4'],
|
||||
['clip.m4v', 'm4v'],
|
||||
['archive.tar.mkv', 'mkv'],
|
||||
['a.b.c.avi', 'avi'],
|
||||
])('extracts %s as %s', (fileName, expected) => {
|
||||
expect(getVideoExtensionFromFileName(fileName)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['payload.exe', 'clip.svg', 'no-extension', '', 'clip.'])(
|
||||
'rejects the file name %s',
|
||||
(fileName) => {
|
||||
expect(getVideoExtensionFromFileName(fileName)).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('resolveVideoContentType', () => {
|
||||
it('keeps a matching mime and extension pair', () => {
|
||||
expect(resolveVideoContentType('clip.mp4', 'video/mp4')).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('lets the file extension win when it disagrees with the declared mime', () => {
|
||||
expect(resolveVideoContentType('clip.mov', 'video/mp4')).toBe('video/quicktime');
|
||||
});
|
||||
|
||||
it('resolves the m4v alias back to video/mp4 rather than treating it as a mismatch', () => {
|
||||
expect(resolveVideoContentType('clip.m4v', 'video/mp4')).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('falls back to the extension when the browser sends application/octet-stream', () => {
|
||||
expect(resolveVideoContentType('clip.webm', 'application/octet-stream')).toBe('video/webm');
|
||||
});
|
||||
|
||||
it('resolves from the extension alone when no mime is supplied', () => {
|
||||
expect(resolveVideoContentType('clip.mkv', undefined)).toBe('video/x-matroska');
|
||||
});
|
||||
|
||||
it('returns null when neither the mime nor the extension is a known video', () => {
|
||||
expect(resolveVideoContentType('payload.exe', 'application/x-msdownload')).toBeNull();
|
||||
});
|
||||
|
||||
// KNOWN GAP asserted as-is: a client-declared video mime is accepted even when
|
||||
// the file name is not a known video extension, because the mismatch branch only
|
||||
// fires when BOTH sides resolve to an extension.
|
||||
it('trusts a declared video mime even for a non-video file name', () => {
|
||||
expect(resolveVideoContentType('payload.exe', 'video/mp4')).toBe('video/mp4');
|
||||
expect(isAllowedVideoFile('payload.exe', 'video/mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a video mime that has no extension mapping of its own', () => {
|
||||
expect(resolveVideoContentType('clip.mp4', 'video/3gpp')).toBe('video/3gpp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedVideoFile', () => {
|
||||
it.each([
|
||||
['clip.mp4', 'video/mp4'],
|
||||
['clip.mov', undefined],
|
||||
['clip.avi', 'application/octet-stream'],
|
||||
])('accepts %s with mime %s', (fileName, mime) => {
|
||||
expect(isAllowedVideoFile(fileName, mime)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['script.js', 'text/javascript'],
|
||||
['image.png', 'image/png'],
|
||||
['no-extension', undefined],
|
||||
])('rejects %s with mime %s', (fileName, mime) => {
|
||||
expect(isAllowedVideoFile(fileName, mime)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('video object key and proxy path helpers', () => {
|
||||
it('round-trips a well-formed filename through both directions', () => {
|
||||
const filename = `${UUID}.mp4`;
|
||||
const objectKey = buildVideoObjectKey(filename);
|
||||
const proxyPath = objectKeyToVideoProxyPath(objectKey);
|
||||
|
||||
expect(objectKey).toBe(`${VIDEO_OBJECT_KEY_PREFIX}${filename}`);
|
||||
expect(proxyPath).toBe(`${VIDEO_PROXY_PREFIX}${filename}`);
|
||||
expect(videoProxyPathToObjectKey(proxyPath!)).toBe(objectKey);
|
||||
});
|
||||
|
||||
it('accepts an uppercase uuid', () => {
|
||||
const path = videoProxyPathFromFilename(`${UUID.toUpperCase()}.MP4`);
|
||||
expect(videoProxyPathToObjectKey(path)).toBe(`videos/${UUID.toUpperCase()}.MP4`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a traversal segment', `${VIDEO_PROXY_PREFIX}../../etc/passwd`],
|
||||
['a nested path', `${VIDEO_PROXY_PREFIX}sub/${UUID}.mp4`],
|
||||
['a non-uuid basename', `${VIDEO_PROXY_PREFIX}clip.mp4`],
|
||||
['a missing extension', `${VIDEO_PROXY_PREFIX}${UUID}`],
|
||||
['a truncated uuid', `${VIDEO_PROXY_PREFIX}${UUID.slice(0, 35)}.mp4`],
|
||||
['an uppercase extension with punctuation', `${VIDEO_PROXY_PREFIX}${UUID}.mp4?x=1`],
|
||||
['the wrong prefix', `/api/upload/image/${UUID}.mp4`],
|
||||
['an absolute url', `https://evil.com${VIDEO_PROXY_PREFIX}${UUID}.mp4`],
|
||||
])('videoProxyPathToObjectKey rejects %s', (_label, path) => {
|
||||
expect(videoProxyPathToObjectKey(path)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the wrong prefix', `uploads/${UUID}.mp4`],
|
||||
['a traversal segment', 'videos/../../secret.mp4'],
|
||||
['a non-uuid basename', 'videos/clip.mp4'],
|
||||
])('objectKeyToVideoProxyPath rejects %s', (_label, key) => {
|
||||
expect(objectKeyToVideoProxyPath(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveR2PlaybackUrl', () => {
|
||||
it('passes an existing proxy path through untouched', () => {
|
||||
const originalUrl = `${VIDEO_PROXY_PREFIX}${UUID}.mp4`;
|
||||
expect(resolveR2PlaybackUrl({ videoId: 'ignored', originalUrl })).toBe(originalUrl);
|
||||
});
|
||||
|
||||
it('converts a stored object key in originalUrl into a proxy path', () => {
|
||||
expect(resolveR2PlaybackUrl({ videoId: 'ignored', originalUrl: `videos/${UUID}.mp4` })).toBe(
|
||||
`${VIDEO_PROXY_PREFIX}${UUID}.mp4`
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to an object key held in videoId', () => {
|
||||
expect(
|
||||
resolveR2PlaybackUrl({ videoId: `videos/${UUID}.webm`, originalUrl: 'https://cdn/x.webm' })
|
||||
).toBe(`${VIDEO_PROXY_PREFIX}${UUID}.webm`);
|
||||
});
|
||||
|
||||
it('returns the original url when neither field carries an object key', () => {
|
||||
expect(
|
||||
resolveR2PlaybackUrl({ videoId: 'abc123', originalUrl: 'https://cdn.example.com/x.mp4' })
|
||||
).toBe('https://cdn.example.com/x.mp4');
|
||||
});
|
||||
|
||||
it('prefers originalUrl over videoId when both look like object keys', () => {
|
||||
expect(
|
||||
resolveR2PlaybackUrl({ videoId: `videos/${UUID}.webm`, originalUrl: `videos/${UUID}.mp4` })
|
||||
).toBe(`${VIDEO_PROXY_PREFIX}${UUID}.mp4`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlayableVideoUrl', () => {
|
||||
it.each([
|
||||
`${VIDEO_PROXY_PREFIX}${UUID}.mp4`,
|
||||
'https://cdn.example.com/clip.mp4',
|
||||
'http://localhost:9000/bucket/clip.mp4',
|
||||
'https://cdn.example.com/anything-at-all',
|
||||
])('accepts %s', (url) => {
|
||||
expect(isPlayableVideoUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`${VIDEO_PROXY_PREFIX}../../etc/passwd`,
|
||||
`${VIDEO_PROXY_PREFIX}clip.mp4`,
|
||||
'javascript:alert(1)',
|
||||
'data:video/mp4;base64,AAAA',
|
||||
'blob:https://example.com/uuid',
|
||||
`videos/${UUID}.mp4`,
|
||||
'',
|
||||
])('rejects %s', (url) => {
|
||||
expect(isPlayableVideoUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeImageMime', () => {
|
||||
it.each([
|
||||
['image/jpg', 'image/jpeg'],
|
||||
['image/pjpeg', 'image/jpeg'],
|
||||
['image/jpeg', 'image/jpeg'],
|
||||
['image/png', 'image/png'],
|
||||
['image/svg+xml', 'image/svg+xml'],
|
||||
])('maps %s to %s', (input, expected) => {
|
||||
expect(normalizeImageMime(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not normalise a case-variant alias', () => {
|
||||
expect(normalizeImageMime('IMAGE/JPG')).toBe('IMAGE/JPG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedImageType', () => {
|
||||
// The list is written out here rather than spread from
|
||||
// ALLOWED_IMAGE_MIME_TYPES. isAllowedImageType() is a `.includes()` over that
|
||||
// same constant, so driving it.each() from the constant made the test data and
|
||||
// the code under test the same thing: dropping 'image/gif' from
|
||||
// lib/image-upload-validation.ts deleted the case that would have caught it and
|
||||
// left the file green while GIF uploads stopped working. getImageExtension()
|
||||
// reads a separate map, so nothing else in the suite noticed either.
|
||||
it.each(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])('accepts %s', (mime) => {
|
||||
expect(isAllowedImageType(mime)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows exactly those four types and nothing else', () => {
|
||||
expect([...ALLOWED_IMAGE_MIME_TYPES]).toEqual([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['image/svg+xml', 'image/jpg', 'image/avif', 'image/bmp', 'text/html', ''])(
|
||||
'rejects %s',
|
||||
(mime) => {
|
||||
expect(isAllowedImageType(mime)).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('detectImageMime', () => {
|
||||
const jpeg = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]);
|
||||
const gif87 = Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0x01]);
|
||||
const gif89 = Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01]);
|
||||
const webp = Uint8Array.from([
|
||||
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
|
||||
]);
|
||||
|
||||
it.each([
|
||||
['a JPEG SOI marker', jpeg, 'image/jpeg'],
|
||||
['a PNG signature', png, 'image/png'],
|
||||
['a GIF87a header', gif87, 'image/gif'],
|
||||
['a GIF89a header', gif89, 'image/gif'],
|
||||
['a RIFF/WEBP header', webp, 'image/webp'],
|
||||
])('detects %s', (_label, buffer, expected) => {
|
||||
expect(detectImageMime(buffer)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty buffer', Uint8Array.from([])],
|
||||
['a one byte buffer', Uint8Array.from([0xff])],
|
||||
['a truncated PNG signature', Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a])],
|
||||
[
|
||||
'a GIF header with the wrong version digit',
|
||||
Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x35, 0x61]),
|
||||
],
|
||||
[
|
||||
'RIFF without the WEBP fourcc',
|
||||
Uint8Array.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x41, 0x56, 0x49, 0x20]),
|
||||
],
|
||||
['a truncated RIFF container', Uint8Array.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x00])],
|
||||
['an SVG document', new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg" />')],
|
||||
['an ELF binary', Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01])],
|
||||
])('returns null for %s', (_label, buffer) => {
|
||||
expect(detectImageMime(buffer)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageExtension', () => {
|
||||
it.each([
|
||||
['image/jpeg', 'jpg'],
|
||||
['image/png', 'png'],
|
||||
['image/webp', 'webp'],
|
||||
['image/gif', 'gif'],
|
||||
] as const)('maps %s to %s', (mime, expected) => {
|
||||
expect(getImageExtension(mime)).toBe(expected);
|
||||
});
|
||||
|
||||
it('has an extension for every allowed mime type', () => {
|
||||
for (const mime of ALLOWED_IMAGE_MIME_TYPES) {
|
||||
expect(getImageExtension(mime)).toMatch(/^[a-z]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstBytesHex', () => {
|
||||
it('renders each byte as two lowercase hex digits separated by spaces', () => {
|
||||
expect(firstBytesHex(Uint8Array.from([0x00, 0x0f, 0xff, 0xa9]))).toBe('00 0f ff a9');
|
||||
});
|
||||
|
||||
it('caps the output at 16 bytes by default', () => {
|
||||
const buffer = Uint8Array.from({ length: 32 }, (_unused, i) => i);
|
||||
expect(firstBytesHex(buffer).split(' ')).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('honours an explicit length', () => {
|
||||
const buffer = Uint8Array.from([0x01, 0x02, 0x03, 0x04]);
|
||||
expect(firstBytesHex(buffer, 2)).toBe('01 02');
|
||||
});
|
||||
|
||||
it('returns an empty string for an empty buffer', () => {
|
||||
expect(firstBytesHex(Uint8Array.from([]))).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isSafeAppRelativePath,
|
||||
isValidHttpUrl,
|
||||
validateAnnotationStrokes,
|
||||
validateOptionalUrl,
|
||||
validateOptionalUrlOrAppPath,
|
||||
validateUrl,
|
||||
} from '@/lib/validation';
|
||||
|
||||
const UUID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
function stroke(overrides: Record<string, unknown> = {}) {
|
||||
return { points: [{ x: 1, y: 2 }], color: '#FF3B30', width: 4, ...overrides };
|
||||
}
|
||||
|
||||
describe('validateAnnotationStrokes', () => {
|
||||
it('returns a normalised copy of a valid single stroke', () => {
|
||||
const input = [{ points: [{ x: 1.5, y: -2.25 }], color: '#0a0B0c', width: 3 }];
|
||||
|
||||
expect(validateAnnotationStrokes(input)).toEqual([
|
||||
{ points: [{ x: 1.5, y: -2.25 }], color: '#0a0B0c', width: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns fresh objects rather than the caller-supplied ones', () => {
|
||||
const point = { x: 1, y: 2 };
|
||||
const input = [{ points: [point], color: '#FF3B30', width: 4 }];
|
||||
|
||||
const result = validateAnnotationStrokes(input);
|
||||
|
||||
expect(result).not.toBe(input);
|
||||
expect(result?.[0]).not.toBe(input[0]);
|
||||
expect(result?.[0].points[0]).not.toBe(point);
|
||||
});
|
||||
|
||||
it('accepts an empty stroke list', () => {
|
||||
expect(validateAnnotationStrokes([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a stroke with an empty point list', () => {
|
||||
expect(validateAnnotationStrokes([stroke({ points: [] })])).toEqual([
|
||||
{ points: [], color: '#FF3B30', width: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('returns null for %s input', (_label, value) => {
|
||||
expect(validateAnnotationStrokes(value)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a plain object', { points: [] }],
|
||||
['a string', '[]'],
|
||||
['a number', 0],
|
||||
['a boolean', true],
|
||||
])('returns null when the payload is %s instead of an array', (_label, value) => {
|
||||
expect(validateAnnotationStrokes(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts exactly 500 strokes', () => {
|
||||
const input = Array.from({ length: 500 }, () => stroke());
|
||||
expect(validateAnnotationStrokes(input)).toHaveLength(500);
|
||||
});
|
||||
|
||||
it('rejects 501 strokes', () => {
|
||||
const input = Array.from({ length: 501 }, () => stroke());
|
||||
expect(validateAnnotationStrokes(input)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts exactly 2000 points in one stroke', () => {
|
||||
const points = Array.from({ length: 2000 }, (_unused, i) => ({ x: i, y: i }));
|
||||
expect(validateAnnotationStrokes([stroke({ points })])?.[0].points).toHaveLength(2000);
|
||||
});
|
||||
|
||||
it('rejects 2001 points in one stroke', () => {
|
||||
const points = Array.from({ length: 2001 }, (_unused, i) => ({ x: i, y: i }));
|
||||
expect(validateAnnotationStrokes([stroke({ points })])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the whole payload when only the last stroke is over the point limit', () => {
|
||||
const points = Array.from({ length: 2001 }, (_unused, i) => ({ x: i, y: i }));
|
||||
expect(validateAnnotationStrokes([stroke(), stroke({ points })])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([1, 20, 1.5, 19.75])('accepts stroke width %s', (width) => {
|
||||
expect(validateAnnotationStrokes([stroke({ width })])?.[0].width).toBe(width);
|
||||
});
|
||||
|
||||
it.each([0, 0.999, -1, 20.0001, 21, 1000])('rejects stroke width %s', (width) => {
|
||||
expect(validateAnnotationStrokes([stroke({ width })])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a numeric string', '4'],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('rejects a stroke whose width is %s', (_label, width) => {
|
||||
expect(validateAnnotationStrokes([stroke({ width })])).toBeNull();
|
||||
});
|
||||
|
||||
// KNOWN GAP in lib/validation.ts, asserted as-is rather than fixed here:
|
||||
// the width check is `width < MIN || width > MAX`, and both comparisons are
|
||||
// false for NaN, so NaN passes the bounds test. The coordinate checks use an
|
||||
// explicit isFinite() guard; the width check does not. A NaN width survives
|
||||
// into the stored annotation JSON, where JSON.stringify renders it as null.
|
||||
it('lets a NaN stroke width through, unlike NaN coordinates', () => {
|
||||
expect(validateAnnotationStrokes([stroke({ width: Number.NaN })])?.[0].width).toBeNaN();
|
||||
});
|
||||
|
||||
it.each(['#FF3B30', '#ff3b30', '#000000', '#AbCdEf'])('accepts colour %s', (color) => {
|
||||
expect(validateAnnotationStrokes([stroke({ color })])?.[0].color).toBe(color);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'#fff',
|
||||
'#FF3B3',
|
||||
'#FF3B301',
|
||||
'FF3B30',
|
||||
'red',
|
||||
'rgb(255,0,0)',
|
||||
'#GGGGGG',
|
||||
'#FF3B30 ',
|
||||
' #FF3B30',
|
||||
'#FF3B30\n',
|
||||
])('rejects colour %s', (color) => {
|
||||
expect(validateAnnotationStrokes([stroke({ color })])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['a number', 16711680],
|
||||
])('rejects a stroke whose colour is %s', (_label, color) => {
|
||||
expect(validateAnnotationStrokes([stroke({ color })])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
['-Infinity', Number.NEGATIVE_INFINITY],
|
||||
])('rejects a point whose x is %s', (_label, x) => {
|
||||
expect(validateAnnotationStrokes([stroke({ points: [{ x, y: 0 }] })])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
])('rejects a point whose y is %s', (_label, y) => {
|
||||
expect(validateAnnotationStrokes([stroke({ points: [{ x: 0, y }] })])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a numeric string', { x: '1', y: 2 }],
|
||||
['missing y', { x: 1 }],
|
||||
['missing x', { y: 2 }],
|
||||
['a null coordinate', { x: null, y: 2 }],
|
||||
])('rejects a point with %s', (_label, point) => {
|
||||
expect(validateAnnotationStrokes([stroke({ points: [point] })])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a nested array where a point object is expected', () => {
|
||||
expect(validateAnnotationStrokes([stroke({ points: [[1, 2]] })])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a null point', () => {
|
||||
expect(validateAnnotationStrokes([stroke({ points: [null] })])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a non-array points value', () => {
|
||||
expect(validateAnnotationStrokes([stroke({ points: { 0: { x: 1, y: 2 } } })])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['an array', []],
|
||||
['a string', 'stroke'],
|
||||
['a number', 1],
|
||||
])('rejects a stroke that is %s', (_label, value) => {
|
||||
expect(validateAnnotationStrokes([value])).toBeNull();
|
||||
});
|
||||
|
||||
it('drops unexpected stroke properties instead of copying them through', () => {
|
||||
const input = [{ ...stroke(), tool: 'eraser', onClick: 'alert(1)' }];
|
||||
|
||||
const result = validateAnnotationStrokes(input);
|
||||
|
||||
expect(Object.keys(result![0]).sort()).toEqual(['color', 'points', 'width']);
|
||||
});
|
||||
|
||||
it('drops unexpected point properties', () => {
|
||||
const input = [stroke({ points: [{ x: 1, y: 2, pressure: 0.5 }] })];
|
||||
|
||||
expect(Object.keys(validateAnnotationStrokes(input)![0].points[0]).sort()).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
it('does not pollute Object.prototype from a __proto__ key on a stroke', () => {
|
||||
const payload = JSON.parse(
|
||||
'[{"points":[{"x":1,"y":2}],"color":"#FF3B30","width":4,"__proto__":{"polluted":"yes"}}]'
|
||||
);
|
||||
|
||||
const result = validateAnnotationStrokes(payload);
|
||||
|
||||
expect(result).toEqual([{ points: [{ x: 1, y: 2 }], color: '#FF3B30', width: 4 }]);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(Object.prototype).not.toHaveProperty('polluted');
|
||||
});
|
||||
|
||||
it('does not pollute Object.prototype from a __proto__ key on a point', () => {
|
||||
const payload = JSON.parse(
|
||||
'[{"points":[{"x":1,"y":2,"__proto__":{"pointPolluted":"yes"}}],"color":"#FF3B30","width":4}]'
|
||||
);
|
||||
|
||||
const result = validateAnnotationStrokes(payload);
|
||||
|
||||
expect(result?.[0].points).toEqual([{ x: 1, y: 2 }]);
|
||||
expect(({} as Record<string, unknown>).pointPolluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a stroke whose width arrives via the prototype chain rather than as an own key', () => {
|
||||
const proto = { width: 4, color: '#FF3B30' };
|
||||
const inherited = Object.create(proto) as Record<string, unknown>;
|
||||
inherited.points = [{ x: 1, y: 2 }];
|
||||
|
||||
// Destructuring does read inherited keys, so this documents that a prototype
|
||||
// carrying the required fields is accepted, and the copy is a plain object.
|
||||
const result = validateAnnotationStrokes([inherited]);
|
||||
|
||||
expect(result).toEqual([{ points: [{ x: 1, y: 2 }], color: '#FF3B30', width: 4 }]);
|
||||
expect(Object.getPrototypeOf(result![0])).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it('accepts a null-prototype stroke object', () => {
|
||||
const nullProto = Object.assign(Object.create(null), stroke());
|
||||
|
||||
expect(validateAnnotationStrokes([nullProto])).toEqual([
|
||||
{ points: [{ x: 1, y: 2 }], color: '#FF3B30', width: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidHttpUrl', () => {
|
||||
it.each([
|
||||
'http://example.com',
|
||||
'https://example.com',
|
||||
'https://example.com/path?query=1#hash',
|
||||
'HTTPS://EXAMPLE.COM',
|
||||
'http://localhost:3000',
|
||||
'https://user:[email protected]',
|
||||
])('accepts %s', (url) => {
|
||||
expect(isValidHttpUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
'data:text/html;base64,PHNjcmlwdD4=',
|
||||
'file:///etc/passwd',
|
||||
'ftp://example.com/file',
|
||||
'vbscript:msgbox(1)',
|
||||
'mailto:[email protected]',
|
||||
'blob:https://example.com/uuid',
|
||||
'//example.com/protocol-relative',
|
||||
'/relative/path',
|
||||
'example.com',
|
||||
'not a url',
|
||||
'',
|
||||
])('rejects %s', (url) => {
|
||||
expect(isValidHttpUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUrl', () => {
|
||||
it('returns null for a valid https URL', () => {
|
||||
expect(validateUrl('https://example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('names the field in the required message', () => {
|
||||
expect(validateUrl('', 'Thumbnail')).toBe('Thumbnail is required');
|
||||
});
|
||||
|
||||
it('names the field in the scheme message', () => {
|
||||
expect(validateUrl('javascript:alert(1)', 'Thumbnail')).toBe(
|
||||
'Thumbnail must be a valid HTTP or HTTPS URL'
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults the field name to URL', () => {
|
||||
expect(validateUrl('javascript:alert(1)')).toBe('URL must be a valid HTTP or HTTPS URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOptionalUrl', () => {
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['an empty string', ''],
|
||||
])('accepts %s', (_label, value) => {
|
||||
expect(validateOptionalUrl(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('still rejects a dangerous scheme', () => {
|
||||
expect(validateOptionalUrl('javascript:alert(1)')).toBe(
|
||||
'URL must be a valid HTTP or HTTPS URL'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeAppRelativePath', () => {
|
||||
it.each([
|
||||
`/api/upload/image/${UUID}.png`,
|
||||
`/api/upload/audio/${UUID}.webm`,
|
||||
`/api/upload/video/${UUID}.mp4`,
|
||||
'/placeholder-video-thumbnail.png',
|
||||
])('accepts %s', (path) => {
|
||||
expect(isSafeAppRelativePath(path)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an uppercase extension because the pattern is case-insensitive', () => {
|
||||
expect(isSafeAppRelativePath(`/api/upload/image/${UUID}.PNG`)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a traversal segment', `/api/upload/image/../../${UUID}.png`],
|
||||
['a traversal after a valid prefix', '/placeholder-video-thumbnail.png/../secret'],
|
||||
['an encoded traversal', `/api/upload/image/%2e%2e/${UUID}.png`],
|
||||
['no leading slash', `api/upload/image/${UUID}.png`],
|
||||
['a protocol-relative prefix', `//evil.com/api/upload/image/${UUID}.png`],
|
||||
['an absolute URL', `https://evil.com/api/upload/image/${UUID}.png`],
|
||||
['an unsupported upload kind', `/api/upload/document/${UUID}.pdf`],
|
||||
['a short identifier', '/api/upload/image/abc.png'],
|
||||
['a 37 character identifier', `/api/upload/image/${UUID}a.png`],
|
||||
['a non-hex identifier', '/api/upload/image/zzzzzzzz-2222-3333-4444-555555555555.png'],
|
||||
['no extension', `/api/upload/image/${UUID}`],
|
||||
['a query string', `/api/upload/image/${UUID}.png?redirect=https://evil.com`],
|
||||
['a trailing slash', `/api/upload/image/${UUID}.png/`],
|
||||
['an unrelated app route', '/api/projects'],
|
||||
['an empty string', ''],
|
||||
])('rejects %s', (_label, path) => {
|
||||
expect(isSafeAppRelativePath(path)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOptionalUrlOrAppPath', () => {
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['an empty string', ''],
|
||||
])('accepts %s', (_label, value) => {
|
||||
expect(validateOptionalUrlOrAppPath(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a safe upload proxy path', () => {
|
||||
expect(validateOptionalUrlOrAppPath(`/api/upload/image/${UUID}.png`)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts an absolute https URL', () => {
|
||||
expect(validateOptionalUrlOrAppPath('https://cdn.example.com/a.png')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an app-relative path that is not on the allowlist', () => {
|
||||
expect(validateOptionalUrlOrAppPath('/api/projects/secret', 'Thumbnail')).toBe(
|
||||
'Thumbnail must be a valid HTTP or HTTPS URL'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a javascript URL with the supplied field name', () => {
|
||||
expect(validateOptionalUrlOrAppPath('javascript:alert(1)', 'Thumbnail')).toBe(
|
||||
'Thumbnail must be a valid HTTP or HTTPS URL'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a traversal attempt dressed up as an upload path', () => {
|
||||
expect(validateOptionalUrlOrAppPath('/api/upload/image/../../../etc/passwd')).toBe(
|
||||
'URL must be a valid HTTP or HTTPS URL'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
detectProvider,
|
||||
fetchVideoMetadata,
|
||||
getAllProviders,
|
||||
getEmbedUrl,
|
||||
getProvider,
|
||||
getProviderIcon,
|
||||
getThumbnailUrl,
|
||||
isValidVideoUrl,
|
||||
parseVideoUrl,
|
||||
type VideoProviderType,
|
||||
} from '@/lib/video-providers';
|
||||
import { getCachedMetadata, setCachedMetadata } from '@/lib/video-providers/metadata-cache';
|
||||
|
||||
const YT_ID = 'dQw4w9WgXcQ';
|
||||
const UUID = '3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', undefined);
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('detectProvider', () => {
|
||||
it.each([
|
||||
[`https://www.youtube.com/watch?v=${YT_ID}`, 'youtube'],
|
||||
[`https://youtube.com/watch?v=${YT_ID}`, 'youtube'],
|
||||
[`https://youtu.be/${YT_ID}`, 'youtube'],
|
||||
[`https://www.youtube.com/shorts/${YT_ID}`, 'youtube'],
|
||||
[`https://www.youtube.com/embed/${YT_ID}`, 'youtube'],
|
||||
[`https://www.youtube.com/v/${YT_ID}`, 'youtube'],
|
||||
[`https://www.youtube.com/watch?v=${YT_ID}&t=30s`, 'youtube'],
|
||||
[`https://www.youtube.com/watch?list=PLabc&v=${YT_ID}`, 'youtube'],
|
||||
[`https://youtu.be/${YT_ID}?si=trackingparam`, 'youtube'],
|
||||
['https://cdn.example.com/clip.mp4', 'direct'],
|
||||
['https://cdn.example.com/clip.webm?token=abc', 'direct'],
|
||||
['http://localhost:9000/bucket/clip.mov', 'direct'],
|
||||
['https://iframe.mediadelivery.net/play/12345/abc-def_1', 'bunny'],
|
||||
['https://video.bunnycdn.com/embed/12345/abcdef', 'bunny'],
|
||||
[`/api/upload/video/${UUID}.mp4`, 'r2'],
|
||||
])('resolves %s to the %s provider', (url, expectedId) => {
|
||||
expect(detectProvider(url)?.id).toBe(expectedId);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://www.youtube.com/watch?v=tooshort',
|
||||
'https://example.com/page.html',
|
||||
'javascript:alert(1)',
|
||||
'ftp://example.com/clip.mp4',
|
||||
'data:video/mp4;base64,AAAA',
|
||||
`/api/upload/video/${UUID}.mp4?range=1`,
|
||||
`/api/upload/image/${UUID}.png`,
|
||||
'clip.mp4',
|
||||
'',
|
||||
])('returns null for %s', (url) => {
|
||||
expect(detectProvider(url)).toBeNull();
|
||||
});
|
||||
|
||||
it('lets the direct provider win over Bunny when a Bunny url ends in a video extension', () => {
|
||||
// Registry order is youtube, direct, bunny, r2, so the extension test matches first.
|
||||
expect(detectProvider('https://iframe.mediadelivery.net/play/12345/abc.mp4')?.id).toBe(
|
||||
'direct'
|
||||
);
|
||||
});
|
||||
|
||||
it('lets YouTube win over the direct provider for a watch url ending in .mp4', () => {
|
||||
expect(detectProvider(`https://www.youtube.com/watch?v=${YT_ID}&file=a.mp4`)?.id).toBe(
|
||||
'youtube'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVideoUrl', () => {
|
||||
it.each([
|
||||
[`https://www.youtube.com/watch?v=${YT_ID}`, YT_ID],
|
||||
[`https://youtu.be/${YT_ID}`, YT_ID],
|
||||
[`https://www.youtube.com/shorts/${YT_ID}`, YT_ID],
|
||||
[`https://www.youtube.com/watch?v=${YT_ID}&list=PLabc`, YT_ID],
|
||||
[`https://www.youtube.com/watch?list=PLabc&index=2&v=${YT_ID}`, YT_ID],
|
||||
])('extracts the YouTube id from %s', (url, expected) => {
|
||||
expect(parseVideoUrl(url)).toEqual({
|
||||
providerId: 'youtube',
|
||||
videoId: expected,
|
||||
originalUrl: url,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the whole url as the id for a direct upload', () => {
|
||||
const url = 'https://cdn.example.com/clip.mp4';
|
||||
expect(parseVideoUrl(url)).toEqual({
|
||||
providerId: 'direct',
|
||||
videoId: url,
|
||||
originalUrl: url,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the proxy path as the id for an r2 upload', () => {
|
||||
const url = `/api/upload/video/${UUID}.mp4`;
|
||||
expect(parseVideoUrl(url)?.videoId).toBe(url);
|
||||
});
|
||||
|
||||
it('extracts the Bunny guid from an iframe url', () => {
|
||||
expect(parseVideoUrl('https://iframe.mediadelivery.net/play/12345/guid-abc')).toEqual({
|
||||
providerId: 'bunny',
|
||||
videoId: 'guid-abc',
|
||||
originalUrl: 'https://iframe.mediadelivery.net/play/12345/guid-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no provider can handle the url', () => {
|
||||
expect(parseVideoUrl('https://example.com/not-a-video')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProvider and getProviderIcon', () => {
|
||||
it.each(['youtube', 'direct', 'bunny', 'r2'] as const)('returns the %s provider by id', (id) => {
|
||||
expect(getProvider(id)?.id).toBe(id);
|
||||
});
|
||||
|
||||
it('returns null for an unregistered id', () => {
|
||||
expect(getProvider('vimeo' as VideoProviderType)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['youtube', 'Youtube'],
|
||||
['direct', 'Upload'],
|
||||
['bunny', 'Video'],
|
||||
['r2', 'Upload'],
|
||||
] as const)('reports the %s icon as %s', (id, icon) => {
|
||||
expect(getProviderIcon(id)).toBe(icon);
|
||||
});
|
||||
|
||||
it('falls back to a generic icon for an unknown provider', () => {
|
||||
expect(getProviderIcon('vimeo' as VideoProviderType)).toBe('Video');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllProviders', () => {
|
||||
it('lists every registered provider', () => {
|
||||
expect(getAllProviders().map((p) => p.id)).toEqual(['youtube', 'direct', 'bunny', 'r2']);
|
||||
});
|
||||
|
||||
it('returns a copy so callers cannot mutate the registry', () => {
|
||||
getAllProviders().length = 0;
|
||||
|
||||
expect(getAllProviders()).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidVideoUrl', () => {
|
||||
it('accepts a url a provider can handle', () => {
|
||||
expect(isValidVideoUrl(`https://youtu.be/${YT_ID}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a url no provider can handle', () => {
|
||||
expect(isValidVideoUrl('https://example.com/article')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('youtube embed and thumbnail urls', () => {
|
||||
function params(url: string): URLSearchParams {
|
||||
return new URL(url).searchParams;
|
||||
}
|
||||
|
||||
it('always enables the JS API and the reduced-branding options', () => {
|
||||
const url = getEmbedUrl({ providerId: 'youtube', videoId: YT_ID, originalUrl: '' })!;
|
||||
|
||||
expect(url.startsWith(`https://www.youtube.com/embed/${YT_ID}?`)).toBe(true);
|
||||
expect(params(url).get('enablejsapi')).toBe('1');
|
||||
expect(params(url).get('rel')).toBe('0');
|
||||
expect(params(url).get('modestbranding')).toBe('1');
|
||||
expect(params(url).get('origin')).toBe('');
|
||||
});
|
||||
|
||||
it('omits optional parameters that were not requested', () => {
|
||||
const url = getEmbedUrl({ providerId: 'youtube', videoId: YT_ID, originalUrl: '' })!;
|
||||
const search = params(url);
|
||||
|
||||
expect(search.has('autoplay')).toBe(false);
|
||||
expect(search.has('start')).toBe(false);
|
||||
expect(search.has('controls')).toBe(false);
|
||||
expect(search.has('mute')).toBe(false);
|
||||
expect(search.has('loop')).toBe(false);
|
||||
});
|
||||
|
||||
it('floors a fractional start time', () => {
|
||||
const url = getEmbedUrl(
|
||||
{ providerId: 'youtube', videoId: YT_ID, originalUrl: '' },
|
||||
{ startTime: 30.9 }
|
||||
)!;
|
||||
|
||||
expect(params(url).get('start')).toBe('30');
|
||||
});
|
||||
|
||||
it('drops a zero start time because the option is falsy', () => {
|
||||
const url = getEmbedUrl(
|
||||
{ providerId: 'youtube', videoId: YT_ID, originalUrl: '' },
|
||||
{ startTime: 0 }
|
||||
)!;
|
||||
|
||||
expect(params(url).has('start')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets controls=0 only when controls are explicitly disabled', () => {
|
||||
const disabled = getEmbedUrl(
|
||||
{ providerId: 'youtube', videoId: YT_ID, originalUrl: '' },
|
||||
{ controls: false }
|
||||
)!;
|
||||
const enabled = getEmbedUrl(
|
||||
{ providerId: 'youtube', videoId: YT_ID, originalUrl: '' },
|
||||
{ controls: true }
|
||||
)!;
|
||||
|
||||
expect(params(disabled).get('controls')).toBe('0');
|
||||
expect(params(enabled).has('controls')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps autoplay, loop and muted onto the YouTube parameter names', () => {
|
||||
const url = getEmbedUrl(
|
||||
{ providerId: 'youtube', videoId: YT_ID, originalUrl: '' },
|
||||
{ autoplay: true, loop: true, muted: true }
|
||||
)!;
|
||||
const search = params(url);
|
||||
|
||||
expect(search.get('autoplay')).toBe('1');
|
||||
expect(search.get('loop')).toBe('1');
|
||||
expect(search.get('mute')).toBe('1');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['small', 'default'],
|
||||
['medium', 'mqdefault'],
|
||||
['large', 'hqdefault'],
|
||||
['maxres', 'maxresdefault'],
|
||||
] as const)('renders the %s thumbnail as %s', (size, file) => {
|
||||
expect(getThumbnailUrl({ providerId: 'youtube', videoId: YT_ID, originalUrl: '' }, size)).toBe(
|
||||
`https://img.youtube.com/vi/${YT_ID}/${file}.jpg`
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to the medium thumbnail', () => {
|
||||
expect(getThumbnailUrl({ providerId: 'youtube', videoId: YT_ID, originalUrl: '' })).toBe(
|
||||
`https://img.youtube.com/vi/${YT_ID}/mqdefault.jpg`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct and r2 embed urls', () => {
|
||||
it('returns the direct url unchanged when no start time is given', () => {
|
||||
const url = 'https://cdn.example.com/clip.mp4';
|
||||
expect(getEmbedUrl({ providerId: 'direct', videoId: url, originalUrl: url })).toBe(url);
|
||||
});
|
||||
|
||||
// The direct provider floors the start time into the query params but then
|
||||
// appends the unfloored value as the media fragment. Asserted as-is.
|
||||
it('appends the unfloored start time as a media fragment for a direct url', () => {
|
||||
const url = 'https://cdn.example.com/clip.mp4';
|
||||
expect(
|
||||
getEmbedUrl({ providerId: 'direct', videoId: url, originalUrl: url }, { startTime: 30.5 })
|
||||
).toBe(`${url}#t=30.5`);
|
||||
});
|
||||
|
||||
it('uses a query parameter rather than a fragment for an r2 proxy path', () => {
|
||||
const path = `/api/upload/video/${UUID}.mp4`;
|
||||
expect(
|
||||
getEmbedUrl({ providerId: 'r2', videoId: path, originalUrl: path }, { startTime: 12.9 })
|
||||
).toBe(`${path}?t=12`);
|
||||
});
|
||||
|
||||
it('returns the r2 proxy path unchanged without a start time', () => {
|
||||
const path = `/api/upload/video/${UUID}.mp4`;
|
||||
expect(getEmbedUrl({ providerId: 'r2', videoId: path, originalUrl: path })).toBe(path);
|
||||
});
|
||||
|
||||
it.each(['direct', 'r2'] as const)('serves the placeholder thumbnail for %s', (providerId) => {
|
||||
expect(getThumbnailUrl({ providerId, videoId: 'anything', originalUrl: '' })).toBe(
|
||||
'/placeholder-video-thumbnail.png'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bunny embed and thumbnail urls', () => {
|
||||
it('falls back to library id 0 when none is configured', () => {
|
||||
const url = getEmbedUrl({ providerId: 'bunny', videoId: 'guid-1', originalUrl: '' })!;
|
||||
|
||||
expect(url.startsWith('https://iframe.mediadelivery.net/embed/0/guid-1?')).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers the public library id over the server one', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', '111');
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '222');
|
||||
|
||||
expect(getEmbedUrl({ providerId: 'bunny', videoId: 'guid-1', originalUrl: '' })).toContain(
|
||||
'/embed/111/guid-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes autoplay, loop and muted through as string booleans', () => {
|
||||
const url = getEmbedUrl(
|
||||
{ providerId: 'bunny', videoId: 'guid-1', originalUrl: '' },
|
||||
{ autoplay: true, loop: true, muted: true }
|
||||
)!;
|
||||
const search = new URL(url).searchParams;
|
||||
|
||||
expect(search.get('autoplay')).toBe('true');
|
||||
expect(search.get('loop')).toBe('true');
|
||||
expect(search.get('muted')).toBe('true');
|
||||
});
|
||||
|
||||
it('returns an empty thumbnail url when no Bunny CDN host is configured', () => {
|
||||
expect(getThumbnailUrl({ providerId: 'bunny', videoId: 'guid-1', originalUrl: '' })).toBe('');
|
||||
});
|
||||
|
||||
it('builds the thumbnail url from the configured Bunny CDN host', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://cdn.example.b-cdn.net/');
|
||||
|
||||
expect(getThumbnailUrl({ providerId: 'bunny', videoId: 'guid-1', originalUrl: '' })).toBe(
|
||||
'https://cdn.example.b-cdn.net/guid-1/thumbnail.jpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmbedUrl and getThumbnailUrl for an unknown provider', () => {
|
||||
it('returns null rather than throwing', () => {
|
||||
const source = { providerId: 'vimeo' as VideoProviderType, videoId: 'x', originalUrl: '' };
|
||||
|
||||
expect(getEmbedUrl(source)).toBeNull();
|
||||
expect(getThumbnailUrl(source)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchVideoMetadata', () => {
|
||||
it('returns null for an unregistered provider', async () => {
|
||||
await expect(
|
||||
fetchVideoMetadata({
|
||||
providerId: 'vimeo' as VideoProviderType,
|
||||
videoId: 'x',
|
||||
originalUrl: '',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('maps the YouTube oEmbed payload onto VideoMetadata', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
title: 'Never Gonna Give You Up',
|
||||
thumbnail_url: 'https://i.ytimg.com/vi/x/hqdefault.jpg',
|
||||
author_name: 'Rick Astley',
|
||||
author_url: 'https://www.youtube.com/@RickAstley',
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await fetchVideoMetadata({
|
||||
providerId: 'youtube',
|
||||
videoId: 'oembed-hit-1',
|
||||
originalUrl: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Never Gonna Give You Up',
|
||||
thumbnailUrl: 'https://i.ytimg.com/vi/x/hqdefault.jpg',
|
||||
author: 'Rick Astley',
|
||||
authorUrl: 'https://www.youtube.com/@RickAstley',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('serves the second request for the same video from the cache', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ title: 'Cached', thumbnail_url: 'https://i.ytimg.com/t.jpg' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const source = { providerId: 'youtube' as const, videoId: 'oembed-hit-2', originalUrl: '' };
|
||||
|
||||
await fetchVideoMetadata(source);
|
||||
const second = await fetchVideoMetadata(source);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(second?.title).toBe('Cached');
|
||||
});
|
||||
|
||||
it('falls back to a generated thumbnail when oEmbed responds with an error status', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }));
|
||||
|
||||
const result = await fetchVideoMetadata({
|
||||
providerId: 'youtube',
|
||||
videoId: 'oembed-miss-1',
|
||||
originalUrl: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'YouTube Video',
|
||||
thumbnailUrl: 'https://img.youtube.com/vi/oembed-miss-1/hqdefault.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the oEmbed request rejects outright', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
||||
|
||||
const result = await fetchVideoMetadata({
|
||||
providerId: 'youtube',
|
||||
videoId: 'oembed-miss-2',
|
||||
originalUrl: '',
|
||||
});
|
||||
|
||||
expect(result?.title).toBe('YouTube Video');
|
||||
});
|
||||
|
||||
it('derives a direct upload title from the file name', async () => {
|
||||
const url = 'https://cdn.example.com/uploads/final-cut-v3.mp4';
|
||||
|
||||
await expect(
|
||||
fetchVideoMetadata({ providerId: 'direct', videoId: url, originalUrl: url })
|
||||
).resolves.toEqual({
|
||||
title: 'final-cut-v3',
|
||||
thumbnailUrl: '/placeholder-video-thumbnail.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a placeholder Bunny title without calling the network', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await fetchVideoMetadata({
|
||||
providerId: 'bunny',
|
||||
videoId: 'bunny-guid-1',
|
||||
originalUrl: '',
|
||||
});
|
||||
|
||||
expect(result?.title).toBe('Bunny Video');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata cache', () => {
|
||||
const value = { title: 'Cached title', thumbnailUrl: 'https://example.com/t.jpg' };
|
||||
|
||||
it('returns null for a key that was never written', () => {
|
||||
expect(getCachedMetadata('cache-test:absent')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the stored value', () => {
|
||||
setCachedMetadata('cache-test:hit', value);
|
||||
|
||||
expect(getCachedMetadata('cache-test:hit')).toEqual(value);
|
||||
});
|
||||
|
||||
it('still returns the value at the exact expiry instant', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'));
|
||||
setCachedMetadata('cache-test:boundary', value, 1000);
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:01.000Z'));
|
||||
|
||||
expect(getCachedMetadata('cache-test:boundary')).toEqual(value);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('drops the value one millisecond past expiry', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'));
|
||||
setCachedMetadata('cache-test:expired', value, 1000);
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:01.001Z'));
|
||||
|
||||
expect(getCachedMetadata('cache-test:expired')).toBeNull();
|
||||
// The expired entry is evicted, so a later read is also a miss.
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'));
|
||||
expect(getCachedMetadata('cache-test:expired')).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('evicts the least recently used entry once 500 entries are exceeded', () => {
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
setCachedMetadata(`lru:${i}`, { ...value, title: `entry-${i}` });
|
||||
}
|
||||
|
||||
// Touching lru:0 makes it the most recently used, so lru:1 becomes the victim.
|
||||
expect(getCachedMetadata('lru:0')?.title).toBe('entry-0');
|
||||
setCachedMetadata('lru:overflow', value);
|
||||
|
||||
expect(getCachedMetadata('lru:1')).toBeNull();
|
||||
expect(getCachedMetadata('lru:0')?.title).toBe('entry-0');
|
||||
expect(getCachedMetadata('lru:overflow')).toEqual(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider extractVideoId guards', () => {
|
||||
it.each(['youtube', 'direct', 'bunny', 'r2'] as const)(
|
||||
'the %s provider returns null for a url it cannot handle',
|
||||
(id) => {
|
||||
expect(getProvider(id)!.extractVideoId('https://example.com/not-a-video')).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('the direct provider rejects a video extension behind a non-http scheme', () => {
|
||||
expect(getProvider('direct')!.extractVideoId('file:///tmp/clip.mp4')).toBeNull();
|
||||
});
|
||||
|
||||
it('the r2 provider rejects a proxy path with a traversal segment', () => {
|
||||
expect(getProvider('r2')!.extractVideoId('/api/upload/video/../../secret.mp4')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata for the self-hosted providers', () => {
|
||||
it('derives an r2 title from the proxy path basename', async () => {
|
||||
const path = `/api/upload/video/${UUID}.mp4`;
|
||||
|
||||
await expect(
|
||||
fetchVideoMetadata({ providerId: 'r2', videoId: path, originalUrl: path })
|
||||
).resolves.toEqual({
|
||||
title: UUID,
|
||||
thumbnailUrl: '/placeholder-video-thumbnail.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a generic title when the r2 id has no path segments', async () => {
|
||||
await expect(
|
||||
fetchVideoMetadata({ providerId: 'r2', videoId: '', originalUrl: '' })
|
||||
).resolves.toMatchObject({ title: 'Video' });
|
||||
});
|
||||
|
||||
it('falls back to a generic title when the direct url has no path segments', async () => {
|
||||
await expect(
|
||||
fetchVideoMetadata({ providerId: 'direct', videoId: '', originalUrl: '' })
|
||||
).resolves.toMatchObject({ title: 'Video' });
|
||||
});
|
||||
|
||||
it('returns null and logs when a provider getMetadata throws', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await fetchVideoMetadata({
|
||||
providerId: 'direct',
|
||||
videoId: null as unknown as string,
|
||||
originalUrl: '',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('builds the Bunny thumbnail from a protocol-less CDN host', async () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'cdn.example.b-cdn.net/');
|
||||
|
||||
const result = await fetchVideoMetadata({
|
||||
providerId: 'bunny',
|
||||
videoId: 'bunny-guid-fallback',
|
||||
originalUrl: '',
|
||||
});
|
||||
|
||||
expect(result?.thumbnailUrl).toBe(
|
||||
'https://cdn.example.b-cdn.net/bunny-guid-fallback/thumbnail.jpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
clampSeekTime,
|
||||
getAdjacentPlaybackSpeed,
|
||||
getFrameIndexAtTime,
|
||||
getFrameStepLabel,
|
||||
getFrameStepSeconds,
|
||||
getPlayheadPercent,
|
||||
isTypingTarget,
|
||||
normalizeFrameRate,
|
||||
resolvePlayerShortcut,
|
||||
resolveSkipAmount,
|
||||
timeFromClientX,
|
||||
} from '@/components/video-page/hooks/video-player-utils';
|
||||
|
||||
const SPEEDS = [0.25, 0.5, 1, 1.5, 2];
|
||||
|
||||
describe('normalizeFrameRate', () => {
|
||||
it('snaps a drifting measurement to a broadcast standard', () => {
|
||||
expect(normalizeFrameRate(29.94)).toBe(29.97);
|
||||
expect(normalizeFrameRate(23.9)).toBe(23.976);
|
||||
expect(normalizeFrameRate(59.8)).toBe(59.94);
|
||||
expect(normalizeFrameRate(24.9)).toBe(25);
|
||||
});
|
||||
|
||||
it('returns an exact standard rate unchanged, except the NTSC-shadowed ones', () => {
|
||||
for (const rate of [23.976, 25, 29.97, 48, 50, 59.94, 120]) {
|
||||
expect(normalizeFrameRate(rate)).toBe(rate);
|
||||
}
|
||||
});
|
||||
|
||||
// KNOWN PRODUCTION BUG, pinned rather than fixed. The tolerance is +/-1.5%
|
||||
// but 23.976/24, 29.97/30 and 59.94/60 are only 0.1% apart, and the lookup
|
||||
// takes the FIRST entry within tolerance rather than the closest. The NTSC
|
||||
// rate always comes first in STANDARD_FRAME_RATES, so 24, 30 and 60 can
|
||||
// never be returned: an exactly-30fps source is reported as 29.97fps, which
|
||||
// is the very frame-count drift the snapping is meant to prevent (~18 frames
|
||||
// off after 10 minutes).
|
||||
it('mislabels exact 24, 30 and 60 fps as their NTSC neighbours', () => {
|
||||
expect(normalizeFrameRate(24)).toBe(23.976);
|
||||
expect(normalizeFrameRate(30)).toBe(29.97);
|
||||
expect(normalizeFrameRate(60)).toBe(59.94);
|
||||
// 30.07 is closer to 30 than to 29.97 and still loses.
|
||||
expect(normalizeFrameRate(30.07)).toBe(29.97);
|
||||
});
|
||||
|
||||
it('keeps a plausible non-standard rate rather than forcing a snap', () => {
|
||||
// 45fps is more than 1.5% away from every standard, so it must survive.
|
||||
expect(normalizeFrameRate(45)).toBe(45);
|
||||
expect(normalizeFrameRate(12)).toBe(12);
|
||||
});
|
||||
|
||||
it('rejects rates outside the 12-120 window', () => {
|
||||
expect(normalizeFrameRate(11.9)).toBeNull();
|
||||
expect(normalizeFrameRate(120.1)).toBeNull();
|
||||
expect(normalizeFrameRate(0)).toBeNull();
|
||||
expect(normalizeFrameRate(-30)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-finite and missing input', () => {
|
||||
expect(normalizeFrameRate(undefined)).toBeNull();
|
||||
expect(normalizeFrameRate(Number.NaN)).toBeNull();
|
||||
expect(normalizeFrameRate(Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFrameStepSeconds', () => {
|
||||
it('is the reciprocal of a measured frame rate', () => {
|
||||
expect(getFrameStepSeconds(25)).toBe(0.04);
|
||||
expect(getFrameStepSeconds(50)).toBe(0.02);
|
||||
});
|
||||
|
||||
it('falls back to one second when no rate has been measured', () => {
|
||||
expect(getFrameStepSeconds(null)).toBe(1);
|
||||
expect(getFrameStepSeconds(0)).toBe(1);
|
||||
expect(getFrameStepSeconds(-25)).toBe(1);
|
||||
expect(getFrameStepSeconds(Number.NaN)).toBe(1);
|
||||
expect(getFrameStepSeconds(Number.POSITIVE_INFINITY)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFrameStepLabel', () => {
|
||||
it('reads "1f" only when a usable frame rate is known', () => {
|
||||
expect(getFrameStepLabel(29.97)).toBe('1f');
|
||||
expect(getFrameStepLabel(null)).toBe('1s');
|
||||
expect(getFrameStepLabel(0)).toBe('1s');
|
||||
expect(getFrameStepLabel(Number.NaN)).toBe('1s');
|
||||
});
|
||||
|
||||
it('agrees with getFrameStepSeconds about which unit is in play', () => {
|
||||
for (const rate of [null, 0, Number.NaN, 24, 25, 60]) {
|
||||
const usesFrames = getFrameStepLabel(rate) === '1f';
|
||||
expect(getFrameStepSeconds(rate) !== 1).toBe(usesFrames);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSkipAmount', () => {
|
||||
it('passes the requested seconds straight through outside frame mode', () => {
|
||||
expect(resolveSkipAmount(-5, { isFrameMode: false, frameStepSeconds: 0.04 })).toBe(-5);
|
||||
expect(resolveSkipAmount(5, { isFrameMode: false, frameStepSeconds: 0.04 })).toBe(5);
|
||||
expect(resolveSkipAmount(0, { isFrameMode: false, frameStepSeconds: 0.04 })).toBe(0);
|
||||
});
|
||||
|
||||
it('collapses any jump to a single frame in frame mode, keeping direction', () => {
|
||||
expect(resolveSkipAmount(5, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(0.04);
|
||||
expect(resolveSkipAmount(30, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(0.04);
|
||||
expect(resolveSkipAmount(-5, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(-0.04);
|
||||
expect(resolveSkipAmount(-30, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(-0.04);
|
||||
});
|
||||
|
||||
it('treats a zero-second request as one frame forward', () => {
|
||||
expect(resolveSkipAmount(0, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(0.04);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampSeekTime', () => {
|
||||
it('keeps a time inside [0, duration]', () => {
|
||||
expect(clampSeekTime(5, 10)).toBe(5);
|
||||
expect(clampSeekTime(-3, 10)).toBe(0);
|
||||
expect(clampSeekTime(25, 10)).toBe(10);
|
||||
expect(clampSeekTime(10, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('pins to zero while the duration is still unknown', () => {
|
||||
expect(clampSeekTime(42, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayheadPercent', () => {
|
||||
it('maps a time onto a 0-100 timeline position', () => {
|
||||
expect(getPlayheadPercent(0, 10)).toBe(0);
|
||||
expect(getPlayheadPercent(2.5, 10)).toBe(25);
|
||||
expect(getPlayheadPercent(10, 10)).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps positions that fall outside the media', () => {
|
||||
expect(getPlayheadPercent(-4, 10)).toBe(0);
|
||||
expect(getPlayheadPercent(14, 10)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 rather than NaN when the duration is unknown', () => {
|
||||
expect(getPlayheadPercent(4, 0)).toBe(0);
|
||||
expect(getPlayheadPercent(4, -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFrameIndexAtTime', () => {
|
||||
it('numbers frames from zero within their half-open interval', () => {
|
||||
expect(getFrameIndexAtTime(0, 25, 60)).toBe(0);
|
||||
expect(getFrameIndexAtTime(0.039, 25, 60)).toBe(0);
|
||||
expect(getFrameIndexAtTime(0.04, 25, 60)).toBe(1);
|
||||
expect(getFrameIndexAtTime(1, 25, 60)).toBe(25);
|
||||
});
|
||||
|
||||
it('does not slip a frame backwards on an inexact boundary', () => {
|
||||
// 1.16 * 25 evaluates to 28.999999999999996 in IEEE-754, so a bare
|
||||
// Math.floor would report frame 28 for the exact start of frame 29.
|
||||
expect(1.16 * 25).toBeLessThan(29);
|
||||
expect(getFrameIndexAtTime(1.16, 25, 60)).toBe(29);
|
||||
// Same trap at 4.1s on a 30fps timeline.
|
||||
expect(4.1 * 30).toBeLessThan(123);
|
||||
expect(getFrameIndexAtTime(4.1, 30, 60)).toBe(123);
|
||||
});
|
||||
|
||||
it('never reports a frame past the end of the media', () => {
|
||||
// 2s at 25fps holds frames 0..49.
|
||||
expect(getFrameIndexAtTime(1.99, 25, 2)).toBe(49);
|
||||
expect(getFrameIndexAtTime(2, 25, 2)).toBe(49);
|
||||
expect(getFrameIndexAtTime(600, 25, 2)).toBe(49);
|
||||
});
|
||||
|
||||
it('reports frame 0 while the duration is unknown', () => {
|
||||
expect(getFrameIndexAtTime(12, 25, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeFromClientX', () => {
|
||||
const rect = { left: 100, width: 200 };
|
||||
|
||||
it('maps a pointer position across the timeline onto a time', () => {
|
||||
expect(timeFromClientX(100, rect, 60)).toBe(0);
|
||||
expect(timeFromClientX(200, rect, 60)).toBe(30);
|
||||
expect(timeFromClientX(300, rect, 60)).toBe(60);
|
||||
});
|
||||
|
||||
it('clamps a pointer that has been dragged off either end', () => {
|
||||
expect(timeFromClientX(-500, rect, 60)).toBe(0);
|
||||
expect(timeFromClientX(5000, rect, 60)).toBe(60);
|
||||
});
|
||||
|
||||
it('returns 0 when the timeline rect is missing or has no width', () => {
|
||||
expect(timeFromClientX(200, null, 60)).toBe(0);
|
||||
expect(timeFromClientX(200, { left: 100, width: 0 }, 60)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAdjacentPlaybackSpeed', () => {
|
||||
it('steps up and down the ladder', () => {
|
||||
expect(getAdjacentPlaybackSpeed(SPEEDS, 1, 1)).toBe(1.5);
|
||||
expect(getAdjacentPlaybackSpeed(SPEEDS, 1, -1)).toBe(0.5);
|
||||
expect(getAdjacentPlaybackSpeed(SPEEDS, 0.5, -1)).toBe(0.25);
|
||||
});
|
||||
|
||||
it('returns null at either end instead of wrapping around', () => {
|
||||
expect(getAdjacentPlaybackSpeed(SPEEDS, 2, 1)).toBeNull();
|
||||
expect(getAdjacentPlaybackSpeed(SPEEDS, 0.25, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it('recovers from an unknown current speed by starting at the slowest', () => {
|
||||
expect(getAdjacentPlaybackSpeed(SPEEDS, 3, 1)).toBe(0.25);
|
||||
expect(getAdjacentPlaybackSpeed(SPEEDS, 3, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty ladder', () => {
|
||||
expect(getAdjacentPlaybackSpeed([], 1, 1)).toBeNull();
|
||||
expect(getAdjacentPlaybackSpeed([], 1, -1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePlayerShortcut', () => {
|
||||
it('maps the play/pause, seek, mute and fullscreen keys', () => {
|
||||
expect(resolvePlayerShortcut({ code: 'Space' })).toBe('toggle-play');
|
||||
expect(resolvePlayerShortcut({ code: 'KeyK' })).toBe('toggle-play');
|
||||
expect(resolvePlayerShortcut({ code: 'ArrowLeft' })).toBe('skip-back');
|
||||
expect(resolvePlayerShortcut({ code: 'ArrowRight' })).toBe('skip-forward');
|
||||
expect(resolvePlayerShortcut({ code: 'KeyJ' })).toBe('jump-back');
|
||||
expect(resolvePlayerShortcut({ code: 'KeyL' })).toBe('jump-forward');
|
||||
expect(resolvePlayerShortcut({ code: 'KeyM' })).toBe('toggle-mute');
|
||||
expect(resolvePlayerShortcut({ code: 'KeyF' })).toBe('toggle-fullscreen');
|
||||
});
|
||||
|
||||
it('maps the arrow keys to speed changes regardless of shift', () => {
|
||||
expect(resolvePlayerShortcut({ code: 'ArrowUp' })).toBe('speed-up');
|
||||
expect(resolvePlayerShortcut({ code: 'ArrowDown' })).toBe('speed-down');
|
||||
expect(resolvePlayerShortcut({ code: 'ArrowUp', shiftKey: true })).toBe('speed-up');
|
||||
expect(resolvePlayerShortcut({ code: 'ArrowDown', shiftKey: true })).toBe('speed-down');
|
||||
});
|
||||
|
||||
it('requires shift for the comma and period speed shortcuts', () => {
|
||||
expect(resolvePlayerShortcut({ code: 'Comma', shiftKey: true })).toBe('speed-down');
|
||||
expect(resolvePlayerShortcut({ code: 'Period', shiftKey: true })).toBe('speed-up');
|
||||
// Unshifted, these must stay available for typing.
|
||||
expect(resolvePlayerShortcut({ code: 'Comma', shiftKey: false })).toBeNull();
|
||||
expect(resolvePlayerShortcut({ code: 'Period' })).toBeNull();
|
||||
});
|
||||
|
||||
it('claims no other key', () => {
|
||||
for (const code of ['KeyA', 'Enter', 'Escape', 'Tab', 'Digit1', 'Slash', 'ShiftLeft']) {
|
||||
expect(resolvePlayerShortcut({ code, shiftKey: true })).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTypingTarget', () => {
|
||||
const asTarget = (target: { tagName?: string; isContentEditable?: boolean }) =>
|
||||
isTypingTarget(target as HTMLElement);
|
||||
|
||||
it('recognises the elements a keystroke should be left alone in', () => {
|
||||
expect(asTarget({ tagName: 'INPUT' })).toBe(true);
|
||||
expect(asTarget({ tagName: 'TEXTAREA' })).toBe(true);
|
||||
expect(asTarget({ tagName: 'DIV', isContentEditable: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the player claim keystrokes anywhere else', () => {
|
||||
expect(asTarget({ tagName: 'BODY', isContentEditable: false })).toBe(false);
|
||||
expect(asTarget({ tagName: 'DIV', isContentEditable: false })).toBe(false);
|
||||
expect(asTarget({ tagName: 'BUTTON' })).toBe(false);
|
||||
// Lowercase never happens for HTML elements, but the check is case
|
||||
// sensitive and that is worth pinning.
|
||||
expect(asTarget({ tagName: 'input' })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user