mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resolvePublicBunnyCdnHostname, resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
beforeEach(() => {
|
||||
// The `unit` project loads no env file, so pin both variables rather than
|
||||
// inheriting whatever the shell exports.
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('resolveServerBunnyCdnHostname', () => {
|
||||
it('returns null when neither variable is configured', () => {
|
||||
expect(resolveServerBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the server variable over the public one', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('server.b-cdn.net');
|
||||
});
|
||||
|
||||
it('falls back to the public variable when the server one is unset', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('public.b-cdn.net');
|
||||
});
|
||||
|
||||
it('falls back to the public variable when the server one is empty', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', '');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('public.b-cdn.net');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a full https url', 'https://cdn.example.b-cdn.net', 'cdn.example.b-cdn.net'],
|
||||
['an http url', 'http://cdn.example.b-cdn.net', 'cdn.example.b-cdn.net'],
|
||||
['a url with a path', 'https://cdn.example.b-cdn.net/videos', 'cdn.example.b-cdn.net'],
|
||||
['a url with a trailing slash', 'https://cdn.example.b-cdn.net/', 'cdn.example.b-cdn.net'],
|
||||
['a url with a query string', 'https://cdn.example.b-cdn.net/?a=1', 'cdn.example.b-cdn.net'],
|
||||
['a bare hostname', 'cdn.example.b-cdn.net', 'cdn.example.b-cdn.net'],
|
||||
['a bare hostname with a trailing slash', 'cdn.example.b-cdn.net/', 'cdn.example.b-cdn.net'],
|
||||
[
|
||||
'a scheme-less url written with slashes',
|
||||
'//cdn.example.b-cdn.net',
|
||||
'//cdn.example.b-cdn.net',
|
||||
],
|
||||
['surrounding whitespace', ' https://cdn.example.b-cdn.net ', 'cdn.example.b-cdn.net'],
|
||||
])('reduces %s to the hostname', (_label, configured, expected) => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', configured);
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe(expected);
|
||||
});
|
||||
|
||||
it('drops the port from a url that carries one', () => {
|
||||
// `URL.hostname` excludes the port, unlike `URL.host`. Callers that compare
|
||||
// this value against a request hostname get the bare host, which is what the
|
||||
// Bunny CDN always serves on.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://cdn.example.b-cdn.net:8443/videos');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('cdn.example.b-cdn.net');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['whitespace only', ' '],
|
||||
])('returns null for %s', (_label, configured) => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', configured);
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a bare host:port, which parses as a url with no hostname', () => {
|
||||
// `new URL('localhost:9000')` succeeds with protocol `localhost:` and an
|
||||
// empty hostname, so it never reaches the string-stripping fallback.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'localhost:9000');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a path attached when the value has no scheme to parse', () => {
|
||||
// The fallback only strips a leading scheme and trailing slashes, so a
|
||||
// scheme-less value with a path is returned as-is rather than as a hostname.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'cdn.example.b-cdn.net/videos');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).toBe('cdn.example.b-cdn.net/videos');
|
||||
});
|
||||
|
||||
it('never returns a value carrying a scheme', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://cdn.example.b-cdn.net');
|
||||
|
||||
expect(resolveServerBunnyCdnHostname()).not.toContain('://');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePublicBunnyCdnHostname', () => {
|
||||
it('returns null when the public variable is unset', () => {
|
||||
expect(resolvePublicBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads only the public variable, ignoring the server-only one', () => {
|
||||
// This runs in the browser bundle, where BUNNY_CDN_URL is never inlined.
|
||||
vi.stubEnv('BUNNY_CDN_URL', 'https://server.b-cdn.net');
|
||||
|
||||
expect(resolvePublicBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('reduces the configured public url to its hostname', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net/videos/');
|
||||
|
||||
expect(resolvePublicBunnyCdnHostname()).toBe('public.b-cdn.net');
|
||||
});
|
||||
|
||||
it('returns the same hostname as the server resolver when only the public url is set', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', 'https://public.b-cdn.net');
|
||||
|
||||
expect(resolvePublicBunnyCdnHostname()).toBe(resolveServerBunnyCdnHostname());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
// Bunny download source resolution. Everything the module decides is a
|
||||
// function of what the CDN answers to a HEAD, so `fetch` is the only boundary
|
||||
// stubbed here. Urls are asserted in full because they are fully deterministic;
|
||||
// nothing in this module is signed.
|
||||
//
|
||||
// The module keeps a 60 second in-process cache keyed on
|
||||
// videoId:quality:preference, so every test uses its own video id unless it is
|
||||
// deliberately exercising the cache.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
resolveBunnyCdnHostname,
|
||||
resolveBunnyDownloadSource,
|
||||
} from '@/lib/bunny-download';
|
||||
|
||||
const HOST = 'cdn.example.b-cdn.net';
|
||||
|
||||
type FetchCall = [string, RequestInit];
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function ok(body = ''): Response {
|
||||
return { ok: true, status: 200, text: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
function notFound(): Response {
|
||||
return { ok: false, status: 404, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer 200 for the listed urls and 404 for everything else. `playlist` is
|
||||
* served as the body of any playlist.m3u8 request.
|
||||
*/
|
||||
function stubCdn(available: string[], playlist?: string): void {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/playlist.m3u8')) {
|
||||
return playlist === undefined ? notFound() : ok(playlist);
|
||||
}
|
||||
return available.includes(url) ? ok() : notFound();
|
||||
});
|
||||
}
|
||||
|
||||
function requestedUrls(): string[] {
|
||||
return (fetchMock.mock.calls as FetchCall[]).map((call) => call[0]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async () => notFound());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubEnv('BUNNY_CDN_URL', `https://${HOST}`);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('resolveBunnyCdnHostname', () => {
|
||||
it('reduces a configured url to its hostname', () => {
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('drops a path and a trailing slash', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', `https://${HOST}/some/path/`);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('accepts a bare hostname with no scheme', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', `${HOST}/`);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('falls back to the public variable', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_CDN_URL', `https://${HOST}`);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBe(HOST);
|
||||
});
|
||||
|
||||
it('returns null when neither variable is set', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a blank value rather than an empty hostname', () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', ' ');
|
||||
|
||||
expect(resolveBunnyCdnHostname()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no CDN configured', () => {
|
||||
it('resolves to null without making a request', async () => {
|
||||
vi.stubEnv('BUNNY_CDN_URL', undefined);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-nohost', null, 'auto')).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the original preference', () => {
|
||||
it('returns the original url when the CDN has one', async () => {
|
||||
stubCdn([`https://${HOST}/vid-orig-1/original`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-orig-1', null, 'original')).resolves.toEqual({
|
||||
sourceType: 'original',
|
||||
quality: null,
|
||||
url: `https://${HOST}/vid-orig-1/original`,
|
||||
});
|
||||
});
|
||||
|
||||
// Asking for the original explicitly means the caller wants the master file
|
||||
// or nothing; falling back to a transcode would silently hand back a
|
||||
// lower-quality file under the same name.
|
||||
it('returns null rather than a transcode when the original is absent', async () => {
|
||||
stubCdn([`https://${HOST}/vid-orig-2/play_1080p.mp4`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-orig-2', null, 'original')).resolves.toBeNull();
|
||||
expect(requestedUrls()).toEqual([`https://${HOST}/vid-orig-2/original`]);
|
||||
});
|
||||
|
||||
it('probes with a HEAD that bypasses the cache', async () => {
|
||||
stubCdn([`https://${HOST}/vid-orig-3/original`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-orig-3', null, 'original');
|
||||
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'HEAD', cache: 'no-store' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the compressed preference', () => {
|
||||
it('never asks for the original', async () => {
|
||||
stubCdn([`https://${HOST}/vid-comp-1/original`, `https://${HOST}/vid-comp-1/play_720p.mp4`]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-comp-1', null, 'compressed');
|
||||
|
||||
expect(source?.sourceType).toBe('compressed');
|
||||
expect(requestedUrls().some((url) => url.endsWith('/original'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the requested quality when that rendition exists', async () => {
|
||||
stubCdn([
|
||||
`https://${HOST}/vid-comp-2/play_720p.mp4`,
|
||||
`https://${HOST}/vid-comp-2/play_1080p.mp4`,
|
||||
]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-comp-2', 720, 'compressed')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: 720,
|
||||
url: `https://${HOST}/vid-comp-2/play_720p.mp4`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the highest available rendition when the requested one is missing', async () => {
|
||||
stubCdn([`https://${HOST}/vid-comp-3/play_480p.mp4`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-comp-3', 1080, 'compressed')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: 480,
|
||||
url: `https://${HOST}/vid-comp-3/play_480p.mp4`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, -720, Number.NaN])(
|
||||
'ignores a requested quality of %s and goes straight to the fallback',
|
||||
async (quality) => {
|
||||
stubCdn([`https://${HOST}/vid-comp-q${quality}/play_360p.mp4`]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource(
|
||||
`vid-comp-q${quality}`,
|
||||
quality,
|
||||
'compressed'
|
||||
);
|
||||
|
||||
expect(source?.quality).toBe(360);
|
||||
expect(requestedUrls().some((url) => url.includes(`play_${quality}p`))).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports an empty url when nothing is available at all', async () => {
|
||||
stubCdn([]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-comp-4', null, 'compressed')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: null,
|
||||
url: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('walks the fallback ladder from highest to lowest', async () => {
|
||||
stubCdn([]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-comp-5', null, 'compressed');
|
||||
|
||||
expect(requestedUrls()).toEqual([
|
||||
`https://${HOST}/vid-comp-5/playlist.m3u8`,
|
||||
`https://${HOST}/vid-comp-5/play_2160p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_1440p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_1080p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_720p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_480p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_360p.mp4`,
|
||||
`https://${HOST}/vid-comp-5/play_240p.mp4`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the playlist hint', () => {
|
||||
it('tries the heights the playlist advertises before the static ladder', async () => {
|
||||
stubCdn(
|
||||
[`https://${HOST}/vid-pl-1/play_720p.mp4`],
|
||||
'#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=1280x720\n720.m3u8\n'
|
||||
);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-pl-1', null, 'compressed');
|
||||
|
||||
expect(source?.url).toBe(`https://${HOST}/vid-pl-1/play_720p.mp4`);
|
||||
// 720 came from the playlist, so it is probed before 2160.
|
||||
expect(requestedUrls()[1]).toBe(`https://${HOST}/vid-pl-1/play_720p.mp4`);
|
||||
});
|
||||
|
||||
it('sorts the advertised heights from highest to lowest', async () => {
|
||||
stubCdn(
|
||||
[],
|
||||
'#EXT-X-STREAM-INF:RESOLUTION=640x360\na\n#EXT-X-STREAM-INF:RESOLUTION=1920x1080\nb\n'
|
||||
);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-pl-2', null, 'compressed');
|
||||
|
||||
expect(requestedUrls().slice(1, 3)).toEqual([
|
||||
`https://${HOST}/vid-pl-2/play_1080p.mp4`,
|
||||
`https://${HOST}/vid-pl-2/play_360p.mp4`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an advertised height that is not a Bunny rendition', async () => {
|
||||
stubCdn([], '#EXT-X-STREAM-INF:RESOLUTION=1600x900\na\n');
|
||||
|
||||
await resolveBunnyDownloadSource('vid-pl-3', null, 'compressed');
|
||||
|
||||
expect(requestedUrls().some((url) => url.includes('play_900p'))).toBe(false);
|
||||
expect(requestedUrls()[1]).toBe(`https://${HOST}/vid-pl-3/play_2160p.mp4`);
|
||||
});
|
||||
|
||||
it('does not probe a playlist height twice when the ladder repeats it', async () => {
|
||||
stubCdn([], '#EXT-X-STREAM-INF:RESOLUTION=1920x1080\na\n');
|
||||
|
||||
await resolveBunnyDownloadSource('vid-pl-4', null, 'compressed');
|
||||
|
||||
const probes = requestedUrls().filter((url) => url.includes('play_1080p'));
|
||||
expect(probes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to the static ladder when the playlist request fails', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/playlist.m3u8')) throw new Error('connection reset');
|
||||
return url.endsWith('play_1440p.mp4') ? ok() : notFound();
|
||||
});
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-pl-5', null, 'compressed');
|
||||
|
||||
expect(source?.quality).toBe(1440);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the auto preference', () => {
|
||||
it('prefers the original when the CDN has one', async () => {
|
||||
stubCdn([`https://${HOST}/vid-auto-1/original`, `https://${HOST}/vid-auto-1/play_1080p.mp4`]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-auto-1', null, 'auto');
|
||||
|
||||
expect(source).toEqual({
|
||||
sourceType: 'original',
|
||||
quality: null,
|
||||
url: `https://${HOST}/vid-auto-1/original`,
|
||||
});
|
||||
expect(requestedUrls()).toEqual([`https://${HOST}/vid-auto-1/original`]);
|
||||
});
|
||||
|
||||
it('falls through to a transcode when there is no original', async () => {
|
||||
stubCdn([`https://${HOST}/vid-auto-2/play_1080p.mp4`]);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-auto-2', null, 'auto')).resolves.toEqual({
|
||||
sourceType: 'compressed',
|
||||
quality: 1080,
|
||||
url: `https://${HOST}/vid-auto-2/play_1080p.mp4`,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours the requested quality on the fall-through path', async () => {
|
||||
stubCdn([
|
||||
`https://${HOST}/vid-auto-3/play_480p.mp4`,
|
||||
`https://${HOST}/vid-auto-3/play_1080p.mp4`,
|
||||
]);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-auto-3', 480, 'auto');
|
||||
|
||||
expect(source?.url).toBe(`https://${HOST}/vid-auto-3/play_480p.mp4`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availability probing', () => {
|
||||
it('retries with a ranged GET when the CDN refuses HEAD', async () => {
|
||||
fetchMock.mockImplementation(async (url: string, init: RequestInit) => {
|
||||
if (init.method === 'HEAD') return { ok: false, status: 405 } as unknown as Response;
|
||||
return { ok: false, status: 206 } as unknown as Response;
|
||||
});
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-probe-1', null, 'original');
|
||||
|
||||
expect(source?.url).toBe(`https://${HOST}/vid-probe-1/original`);
|
||||
expect(fetchMock.mock.calls[1][1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Range: 'bytes=0-0' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a plain 200 on the ranged retry too', async () => {
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) =>
|
||||
init.method === 'HEAD'
|
||||
? ({ ok: false, status: 405 } as unknown as Response)
|
||||
: ({ ok: true, status: 200 } as unknown as Response)
|
||||
);
|
||||
|
||||
const source = await resolveBunnyDownloadSource('vid-probe-2', null, 'original');
|
||||
|
||||
expect(source).not.toBeNull();
|
||||
});
|
||||
|
||||
it('treats the file as absent when the ranged retry also fails', async () => {
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) =>
|
||||
init.method === 'HEAD'
|
||||
? ({ ok: false, status: 405 } as unknown as Response)
|
||||
: ({ ok: false, status: 403 } as unknown as Response)
|
||||
);
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-probe-3', null, 'original')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not retry a status other than 405', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 403 } as unknown as Response);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-probe-4', null, 'original');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a rejected probe as absent rather than propagating it', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('dns failure'));
|
||||
|
||||
await expect(resolveBunnyDownloadSource('vid-probe-5', null, 'original')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the resolution cache', () => {
|
||||
it('serves a repeat lookup without touching the CDN again', async () => {
|
||||
stubCdn([`https://${HOST}/vid-cache-1/original`]);
|
||||
|
||||
const first = await resolveBunnyDownloadSource('vid-cache-1', null, 'auto');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
const second = await resolveBunnyDownloadSource('vid-cache-1', null, 'auto');
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('caches a negative result so a missing original is not re-probed', async () => {
|
||||
stubCdn([]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-2', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
await expect(resolveBunnyDownloadSource('vid-cache-2', null, 'original')).resolves.toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('keys the cache on the preference', async () => {
|
||||
stubCdn([`https://${HOST}/vid-cache-3/play_720p.mp4`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-3', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
await resolveBunnyDownloadSource('vid-cache-3', null, 'compressed');
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('keys the cache on the requested quality', async () => {
|
||||
stubCdn([
|
||||
`https://${HOST}/vid-cache-4/play_720p.mp4`,
|
||||
`https://${HOST}/vid-cache-4/play_1080p.mp4`,
|
||||
]);
|
||||
|
||||
const low = await resolveBunnyDownloadSource('vid-cache-4', 720, 'compressed');
|
||||
const high = await resolveBunnyDownloadSource('vid-cache-4', 1080, 'compressed');
|
||||
|
||||
expect(low?.quality).toBe(720);
|
||||
expect(high?.quality).toBe(1080);
|
||||
});
|
||||
|
||||
it('re-probes once the sixty second window has passed', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'));
|
||||
stubCdn([`https://${HOST}/vid-cache-5/original`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-5', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-15T00:01:00.001Z'));
|
||||
await resolveBunnyDownloadSource('vid-cache-5', null, 'original');
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
||||
});
|
||||
|
||||
it('still serves from the cache one millisecond before expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'));
|
||||
stubCdn([`https://${HOST}/vid-cache-6/original`]);
|
||||
|
||||
await resolveBunnyDownloadSource('vid-cache-6', null, 'original');
|
||||
const callsAfterFirst = fetchMock.mock.calls.length;
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-15T00:00:59.999Z'));
|
||||
await resolveBunnyDownloadSource('vid-cache-6', null, 'original');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWithTimeout', () => {
|
||||
it('passes an abort signal through to fetch', async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
|
||||
await fetchWithTimeout('https://example.com/a', { method: 'HEAD' });
|
||||
|
||||
const init = (fetchMock.mock.calls[0] as FetchCall)[1];
|
||||
expect(init.method).toBe('HEAD');
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('aborts a request that has not answered within eight seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
// Never settles, so the only thing that can end the request is the timeout.
|
||||
fetchMock.mockImplementation((_url: string, init: RequestInit) => {
|
||||
signal = init.signal ?? undefined;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
|
||||
void fetchWithTimeout('https://example.com/slow', {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7999);
|
||||
expect(signal?.aborted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not abort a request that answered in time', async () => {
|
||||
vi.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
signal = init.signal ?? undefined;
|
||||
return ok();
|
||||
});
|
||||
|
||||
await fetchWithTimeout('https://example.com/fast', {});
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
|
||||
expect(signal?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
// Orphan deletion on the Bunny side. Every assertion here is really the same
|
||||
// question asked from a different angle: does this module ever issue a DELETE
|
||||
// for something it was not handed as a live Bunny reference?
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type BunnyVideoRef,
|
||||
cleanupBunnyStreamVideos,
|
||||
cleanupBunnyStreamVideosBestEffort,
|
||||
} from '@/lib/bunny-stream-cleanup';
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function deletedIds(): string[] {
|
||||
return fetchMock.mock.calls.map((call) => String(call[0]).split('/videos/')[1]);
|
||||
}
|
||||
|
||||
function bunnyRefs(...videoIds: string[]): BunnyVideoRef[] {
|
||||
return videoIds.map((videoId) => ({ providerId: 'bunny', videoId }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-api-key-unit');
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('which references get deleted', () => {
|
||||
// The provider filter is the safety net. A reference that belongs to another
|
||||
// provider names a live object somewhere else; deleting it by Bunny id would
|
||||
// be meaningless at best, and the same guard is what stops a caller passing a
|
||||
// mixed list from wiping rows it only meant to inspect.
|
||||
it.each(['r2', 'youtube', 'direct', 'BUNNY', ''])(
|
||||
'never deletes a reference whose provider is %s',
|
||||
async (providerId) => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort([
|
||||
{ providerId, videoId: 'live-video-id-1' },
|
||||
]);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ attempted: 0, failed: 0, failedIds: [] });
|
||||
}
|
||||
);
|
||||
|
||||
it('deletes only the Bunny references out of a mixed list', async () => {
|
||||
await cleanupBunnyStreamVideosBestEffort([
|
||||
{ providerId: 'r2', videoId: 'r2-object-key-1' },
|
||||
{ providerId: 'bunny', videoId: 'bunny-video-id-1' },
|
||||
{ providerId: 'youtube', videoId: 'dQw4w9WgXcQ' },
|
||||
]);
|
||||
|
||||
expect(deletedIds()).toEqual(['bunny-video-id-1']);
|
||||
});
|
||||
|
||||
it('does nothing at all for an empty list', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort([]);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ attempted: 0, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it('skips a Bunny reference with an empty video id', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs(''));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result.attempted).toBe(0);
|
||||
});
|
||||
|
||||
// The id goes straight into the request path, so anything that is not the
|
||||
// Bunny guid alphabet is dropped rather than sent.
|
||||
it.each([
|
||||
['too short', 'abc1234'],
|
||||
['a path traversal', '../../library/1/videos/other'],
|
||||
['a slash', 'bunny/video'],
|
||||
['a space', 'bunny video id'],
|
||||
['a wildcard', '*'],
|
||||
['a sql fragment', "abcdefgh'; DROP TABLE videos; --"],
|
||||
['over 128 characters', 'a'.repeat(129)],
|
||||
])('skips an id containing %s', async (_label, videoId) => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs(videoId));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ attempted: 0, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it.each([8, 128])('accepts an id of exactly %i characters', async (length) => {
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs('a'.repeat(length)));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before validating and sending', async () => {
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs(' bunny-video-id-1 '));
|
||||
|
||||
expect(deletedIds()).toEqual(['bunny-video-id-1']);
|
||||
});
|
||||
|
||||
it('deletes a repeated id once', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-1', ' bunny-video-id-1 ')
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.attempted).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the delete request', () => {
|
||||
it('sends a keyed DELETE to the library video endpoint', async () => {
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://video.bunnycdn.com/library/4242/videos/bunny-video-id-1',
|
||||
{ method: 'DELETE', headers: { AccessKey: 'bunny-api-key-unit' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the public library id when the server one is unset', async () => {
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', '9001');
|
||||
|
||||
await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('/library/9001/videos/');
|
||||
});
|
||||
|
||||
it.each(['BUNNY_STREAM_API_KEY', 'BUNNY_STREAM_LIBRARY_ID'])(
|
||||
'reports every id as failed when %s is missing',
|
||||
async (missing) => {
|
||||
vi.stubEnv(missing, undefined);
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-2')
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
attempted: 2,
|
||||
failed: 2,
|
||||
failedIds: ['bunny-video-id-1', 'bunny-video-id-2'],
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('how each response is scored', () => {
|
||||
it('counts a 2xx as deleted', async () => {
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it('counts a 404 as already deleted rather than a failure', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 0, failedIds: [] });
|
||||
});
|
||||
|
||||
it.each([401, 403, 429, 500])('counts a %i as a failure', async (status) => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status });
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 1, failedIds: ['bunny-video-id-1'] });
|
||||
});
|
||||
|
||||
it('counts a rejected request as a failure', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('socket hang up'));
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(bunnyRefs('bunny-video-id-1'));
|
||||
|
||||
expect(result).toEqual({ attempted: 1, failed: 1, failedIds: ['bunny-video-id-1'] });
|
||||
});
|
||||
|
||||
it('keeps deleting the rest after one id fails', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
url.endsWith('bunny-video-id-2') ? { ok: false, status: 500 } : { ok: true, status: 200 }
|
||||
);
|
||||
|
||||
const result = await cleanupBunnyStreamVideosBestEffort(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-2', 'bunny-video-id-3')
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual({ attempted: 3, failed: 1, failedIds: ['bunny-video-id-2'] });
|
||||
});
|
||||
|
||||
it('holds at most five deletes in flight', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
fetchMock.mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
return new Promise((resolve) => {
|
||||
release.push(() => {
|
||||
inFlight -= 1;
|
||||
resolve({ ok: true, status: 200 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const ids = Array.from({ length: 12 }, (_unused, index) => `bunny-video-id-${index + 100}`);
|
||||
const pending = cleanupBunnyStreamVideosBestEffort(bunnyRefs(...ids));
|
||||
|
||||
// Drain in waves: whatever is queued right now, then whatever that unblocks.
|
||||
while (release.length > 0) {
|
||||
release.shift()!();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await pending;
|
||||
|
||||
expect(peak).toBe(5);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupBunnyStreamVideos', () => {
|
||||
it('resolves when every delete succeeded', async () => {
|
||||
await expect(cleanupBunnyStreamVideos(bunnyRefs('bunny-video-id-1'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves when there was nothing to delete', async () => {
|
||||
await expect(cleanupBunnyStreamVideos([])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws with a count and the first three failed ids', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
await expect(
|
||||
cleanupBunnyStreamVideos(
|
||||
bunnyRefs('bunny-video-id-1', 'bunny-video-id-2', 'bunny-video-id-3', 'bunny-video-id-4')
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Bunny cleanup failed for 4 video(s): bunny-video-id-1, bunny-video-id-2, bunny-video-id-3'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
|
||||
const SECRET = 'bunny-upload-token-test-secret';
|
||||
const OTHER_SECRET = 'a-completely-different-secret';
|
||||
const NOW = new Date('2026-01-15T12:00:00.000Z');
|
||||
const NOW_SECONDS = Math.floor(NOW.getTime() / 1000);
|
||||
const ONE_HOUR = 60 * 60;
|
||||
|
||||
const SUBJECT = {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
videoId: 'video-1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mints a token over an arbitrary payload with a valid signature. No signature is
|
||||
* ever hardcoded here, because it depends on the configured secret; every
|
||||
* expectation is about behaviour. This helper exists only to reach the
|
||||
* payload-shape checks, which a forged signature can never get past.
|
||||
*/
|
||||
function signArbitrary(payload: unknown, secret = SECRET): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs raw JSON text rather than an object. JSON.stringify cannot emit a
|
||||
* non-finite number, so this is the only way to hand verify() a payload whose
|
||||
* `iat` or `exp` parses back as Infinity: a decimal exponent that overflows to
|
||||
* it, which JSON.parse accepts and turns into Infinity.
|
||||
*/
|
||||
function signRawJson(json: string, secret = SECRET): string {
|
||||
const encoded = Buffer.from(json, 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
function wellFormedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
typ: 'bunny-upload',
|
||||
uid: SUBJECT.userId,
|
||||
pid: SUBJECT.projectId,
|
||||
vid: SUBJECT.videoId,
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decodePayload(token: string): Record<string, unknown> {
|
||||
const [encoded] = token.split('.');
|
||||
return JSON.parse(Buffer.from(encoded!, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', SECRET);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('createBunnyUploadToken', () => {
|
||||
it('produces a two-part token separated by a dot', () => {
|
||||
expect(createBunnyUploadToken(SUBJECT).split('.')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('encodes the subject and the issue and expiry times into the payload', () => {
|
||||
expect(decodePayload(createBunnyUploadToken(SUBJECT))).toEqual({
|
||||
typ: 'bunny-upload',
|
||||
uid: 'user-1',
|
||||
pid: 'project-1',
|
||||
vid: 'video-1',
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to a one hour lifetime', () => {
|
||||
const payload = decodePayload(createBunnyUploadToken(SUBJECT));
|
||||
|
||||
expect((payload.exp as number) - (payload.iat as number)).toBe(3600);
|
||||
});
|
||||
|
||||
it('honours an explicit ttl', () => {
|
||||
const payload = decodePayload(createBunnyUploadToken(SUBJECT, 120));
|
||||
|
||||
expect((payload.exp as number) - (payload.iat as number)).toBe(120);
|
||||
});
|
||||
|
||||
it('uses base64url, so the token survives a query string unescaped', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||
expect(encodeURIComponent(token)).toBe(token);
|
||||
});
|
||||
|
||||
it('prefers BUNNY_UPLOAD_TOKEN_SECRET over NEXTAUTH_SECRET', () => {
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
// With only NEXTAUTH_SECRET left, verification must fail, which it can only
|
||||
// do if the dedicated variable was the one that signed.
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to NEXTAUTH_SECRET when the dedicated secret is unset', () => {
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyBunnyUploadToken(createBunnyUploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to mint a token when no secret is configured at all', () => {
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
expect(() => createBunnyUploadToken(SUBJECT)).toThrow(
|
||||
'Missing BUNNY_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBunnyUploadToken', () => {
|
||||
it('accepts a freshly signed token for the subject it was minted for', () => {
|
||||
expect(verifyBunnyUploadToken(createBunnyUploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token whose payload was tampered with', () => {
|
||||
const [encodedPayload, signature] = createBunnyUploadToken(SUBJECT).split('.');
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload!, 'base64url').toString('utf8'));
|
||||
payload.pid = 'project-victim';
|
||||
const forged = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
|
||||
expect(
|
||||
verifyBunnyUploadToken(`${forged}.${signature}`, { ...SUBJECT, projectId: 'project-victim' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token whose signature was tampered with', () => {
|
||||
const [encodedPayload, signature] = createBunnyUploadToken(SUBJECT).split('.');
|
||||
const flipped = (signature![0] === 'A' ? 'B' : 'A') + signature!.slice(1);
|
||||
|
||||
expect(verifyBunnyUploadToken(`${encodedPayload}.${flipped}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token signed under a different secret', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token signed by the R2 grant path, which uses the same algorithm', () => {
|
||||
// Both modules HMAC-SHA256 a base64url payload and both fall back to
|
||||
// NEXTAUTH_SECRET, so the `typ` discriminator is the only thing keeping an
|
||||
// R2 grant from being replayed as a Bunny grant.
|
||||
const token = signArbitrary({
|
||||
typ: 'r2-upload',
|
||||
uid: SUBJECT.userId,
|
||||
pid: SUBJECT.projectId,
|
||||
vid: SUBJECT.videoId,
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
});
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token that has expired', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('still accepts a token in its final second', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 59_000));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a token at the exact expiry second and rejects it one second later', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 60_000));
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(true);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a different user', { userId: 'user-2' }],
|
||||
['a different project', { projectId: 'project-2' }],
|
||||
['a different video', { videoId: 'video-2' }],
|
||||
])('rejects a valid token presented for %s', (_label, override) => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
expect(verifyBunnyUploadToken(token, { ...SUBJECT, ...override })).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['whitespace', ' '],
|
||||
['a single segment', 'notatoken'],
|
||||
['three segments', 'a.b.c'],
|
||||
['a missing signature', 'YWJj.'],
|
||||
['a missing payload', '.c2ln'],
|
||||
['two empty segments', '.'],
|
||||
['a jwt-shaped token', 'eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1c2VyLTEifQ.sig'],
|
||||
['punctuation only', '!!!.???'],
|
||||
])('refuses %s rather than throwing', (_label, token) => {
|
||||
expect(() => verifyBunnyUploadToken(token, SUBJECT)).not.toThrow();
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a signature of the wrong length without letting timingSafeEqual throw', () => {
|
||||
const [encodedPayload] = createBunnyUploadToken(SUBJECT).split('.');
|
||||
|
||||
// crypto.timingSafeEqual throws on unequal buffer lengths, so the length
|
||||
// guard in front of it is load bearing.
|
||||
expect(() => verifyBunnyUploadToken(`${encodedPayload}.short`, SUBJECT)).not.toThrow();
|
||||
expect(verifyBunnyUploadToken(`${encodedPayload}.short`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is not JSON', () => {
|
||||
const encoded = Buffer.from('not json at all', 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', SECRET).update(encoded).digest('base64url');
|
||||
|
||||
expect(verifyBunnyUploadToken(`${encoded}.${signature}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is a JSON scalar rather than an object', () => {
|
||||
expect(verifyBunnyUploadToken(signArbitrary('user-1'), SUBJECT)).toBe(false);
|
||||
expect(verifyBunnyUploadToken(signArbitrary(null), SUBJECT)).toBe(false);
|
||||
expect(verifyBunnyUploadToken(signArbitrary(42), SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['typ'], ['uid'], ['pid'], ['vid'], ['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload missing %s',
|
||||
(field) => {
|
||||
const payload = wellFormedPayload();
|
||||
delete (payload as Record<string, unknown>)[field];
|
||||
|
||||
expect(verifyBunnyUploadToken(signArbitrary(payload), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses a correctly signed payload whose exp is a numeric string', () => {
|
||||
const token = signArbitrary(wellFormedPayload({ exp: String(NOW_SECONDS + ONE_HOUR) }));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload whose iat arrives as null', () => {
|
||||
// Named for what it actually exercises. JSON.stringify writes Infinity as
|
||||
// `null`, so a payload minted from a non-finite number reaches verify() as
|
||||
// null and is rejected one line earlier, by `typeof payload.iat === 'number'`.
|
||||
// The Number.isFinite guard is never consulted on this path; the two tests
|
||||
// below are the ones that reach it.
|
||||
const token = signArbitrary(wellFormedPayload({ iat: Number.POSITIVE_INFINITY }));
|
||||
|
||||
expect(decodePayload(token).iat).toBeNull();
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload whose %s is a JSON literal that overflows to Infinity',
|
||||
(field) => {
|
||||
// The one way a non-finite number survives the wire: `1e999` is legal JSON
|
||||
// and JSON.parse turns it into Infinity, which passes the typeof check and
|
||||
// leaves Number.isFinite as the only thing standing. For exp that matters,
|
||||
// because Infinity < now is false, so without the guard the token would
|
||||
// verify and never expire. Minting one still needs the server secret, so
|
||||
// this is defence in depth rather than a reachable forgery.
|
||||
const json = JSON.stringify(wellFormedPayload()).replace(
|
||||
new RegExp(`"${field}":\\d+`),
|
||||
`"${field}":1e999`
|
||||
);
|
||||
|
||||
expect(JSON.parse(json)[field]).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(verifyBunnyUploadToken(signRawJson(json), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts a correctly signed payload carrying unknown extra fields', () => {
|
||||
// The shape check allowlists the fields it needs rather than rejecting
|
||||
// extras, so a token minted by a newer version still verifies.
|
||||
const token = signArbitrary(wellFormedPayload({ scope: 'tus', v: 2 }));
|
||||
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false rather than throwing when the server has no secret configured', () => {
|
||||
const token = createBunnyUploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
// A misconfigured server is indistinguishable from a forged token here.
|
||||
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getMultipartProgressPercent,
|
||||
getPartByteRange,
|
||||
getRetryDelayMs,
|
||||
getUploadProgressPercent,
|
||||
PART_RETRY_DELAYS_MS,
|
||||
} from '@/lib/client/upload-chunking';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
/** The S3 floor for a non-final part, and the smallest size the host can configure. */
|
||||
const MIN_PART_SIZE = 5 * MIB;
|
||||
/** The default `OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES`. */
|
||||
const DEFAULT_PART_SIZE = 32 * MIB;
|
||||
|
||||
/**
|
||||
* The part list is built by the r2-init route, which sizes it with a ceiling
|
||||
* division over the same file length. Mirroring that here (rather than importing
|
||||
* it) keeps these expectations independent of the module under test.
|
||||
*/
|
||||
function partNumbers(totalBytes: number, partSizeBytes: number): number[] {
|
||||
const count = Math.ceil(totalBytes / partSizeBytes);
|
||||
return Array.from({ length: count }, (_unused, index) => index + 1);
|
||||
}
|
||||
|
||||
function rangesFor(totalBytes: number, partSizeBytes: number) {
|
||||
return partNumbers(totalBytes, partSizeBytes).map((partNumber) =>
|
||||
getPartByteRange(partNumber, partSizeBytes, totalBytes)
|
||||
);
|
||||
}
|
||||
|
||||
describe('PART_RETRY_DELAYS_MS', () => {
|
||||
it('gives a failing part three retries over at most 17 seconds', () => {
|
||||
expect(PART_RETRY_DELAYS_MS).toEqual([0, 2000, 5000, 10000]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRetryDelayMs', () => {
|
||||
it('runs the first attempt immediately', () => {
|
||||
expect(getRetryDelayMs(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('backs off further on each retry', () => {
|
||||
expect(getRetryDelayMs(1)).toBe(2000);
|
||||
expect(getRetryDelayMs(2)).toBe(5000);
|
||||
expect(getRetryDelayMs(3)).toBe(10000);
|
||||
});
|
||||
|
||||
it('reads the delay from a caller-supplied schedule', () => {
|
||||
expect(getRetryDelayMs(1, [0, 50])).toBe(50);
|
||||
expect(getRetryDelayMs(2, [0, 50, 75])).toBe(75);
|
||||
});
|
||||
|
||||
// Guards the `?? 0` fallback: without it the caller would await
|
||||
// setTimeout(undefined), which fires immediately and turns a bounded backoff
|
||||
// into a hot loop.
|
||||
it('waits not at all past the end of the schedule', () => {
|
||||
expect(getRetryDelayMs(4)).toBe(0);
|
||||
expect(getRetryDelayMs(99)).toBe(0);
|
||||
expect(getRetryDelayMs(-1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPartByteRange', () => {
|
||||
it('gives each part a full, non-overlapping slice', () => {
|
||||
expect(getPartByteRange(1, MIN_PART_SIZE, 15 * MIB)).toEqual({ start: 0, end: 5 * MIB });
|
||||
expect(getPartByteRange(2, MIN_PART_SIZE, 15 * MIB)).toEqual({
|
||||
start: 5 * MIB,
|
||||
end: 10 * MIB,
|
||||
});
|
||||
expect(getPartByteRange(3, MIN_PART_SIZE, 15 * MIB)).toEqual({
|
||||
start: 10 * MIB,
|
||||
end: 15 * MIB,
|
||||
});
|
||||
});
|
||||
|
||||
it('splits a file that lands exactly on a part boundary into whole parts', () => {
|
||||
const ranges = rangesFor(15 * MIB, MIN_PART_SIZE);
|
||||
|
||||
expect(ranges).toHaveLength(3);
|
||||
// No short tail: the last part is as long as the others and stops on the
|
||||
// last byte of the file.
|
||||
expect(ranges[2].end - ranges[2].start).toBe(MIN_PART_SIZE);
|
||||
expect(ranges[2].end).toBe(15 * MIB);
|
||||
});
|
||||
|
||||
it('gives one byte over a boundary its own one-byte part', () => {
|
||||
const totalBytes = 15 * MIB + 1;
|
||||
const ranges = rangesFor(totalBytes, MIN_PART_SIZE);
|
||||
|
||||
expect(ranges).toHaveLength(4);
|
||||
expect(ranges[3]).toEqual({ start: 15 * MIB, end: totalBytes });
|
||||
expect(ranges[3].end - ranges[3].start).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the remainder to the final part when the file is one byte short', () => {
|
||||
const totalBytes = 15 * MIB - 1;
|
||||
const ranges = rangesFor(totalBytes, MIN_PART_SIZE);
|
||||
|
||||
expect(ranges).toHaveLength(3);
|
||||
expect(ranges[2]).toEqual({ start: 10 * MIB, end: totalBytes });
|
||||
expect(ranges[2].end - ranges[2].start).toBe(MIN_PART_SIZE - 1);
|
||||
});
|
||||
|
||||
it('covers every byte of the file exactly once, whatever the remainder', () => {
|
||||
for (const totalBytes of [
|
||||
1,
|
||||
MIN_PART_SIZE - 1,
|
||||
MIN_PART_SIZE,
|
||||
MIN_PART_SIZE + 1,
|
||||
3 * MIN_PART_SIZE + 7,
|
||||
DEFAULT_PART_SIZE * 4,
|
||||
DEFAULT_PART_SIZE * 4 + 12345,
|
||||
]) {
|
||||
for (const partSize of [MIN_PART_SIZE, DEFAULT_PART_SIZE]) {
|
||||
const ranges = rangesFor(totalBytes, partSize);
|
||||
expect(ranges[0].start).toBe(0);
|
||||
expect(ranges[ranges.length - 1].end).toBe(totalBytes);
|
||||
for (let index = 1; index < ranges.length; index += 1) {
|
||||
expect(ranges[index].start).toBe(ranges[index - 1].end);
|
||||
}
|
||||
const uploaded = ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
|
||||
expect(uploaded).toBe(totalBytes);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('still lands on the last byte at the 10000-part S3 ceiling', () => {
|
||||
// 10000 parts of 5 MiB is the largest upload the smallest allowed part size
|
||||
// can express, and the offsets there are past 2^35, so this is where an
|
||||
// arithmetic slip would first show up as a truncated or duplicated part.
|
||||
const totalBytes = 10000 * MIN_PART_SIZE;
|
||||
const last = getPartByteRange(10000, MIN_PART_SIZE, totalBytes);
|
||||
|
||||
expect(last.start).toBe(9999 * MIN_PART_SIZE);
|
||||
expect(last.end).toBe(totalBytes);
|
||||
expect(Number.isSafeInteger(last.start)).toBe(true);
|
||||
});
|
||||
|
||||
it('produces an empty range for a zero-byte file', () => {
|
||||
// Unreachable today: r2-init rejects sizeBytes <= 0 before any part is
|
||||
// presigned. Pinned because the arithmetic must not produce a negative
|
||||
// length if that ever changes.
|
||||
expect(getPartByteRange(1, MIN_PART_SIZE, 0)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it('reports a part past the end of the file as an empty slice, not a negative one', () => {
|
||||
// A server that over-counted parts would send part 4 for a 15 MiB file.
|
||||
// `end` below `start` is what Blob.slice reads as empty, so the request goes
|
||||
// out with no bytes rather than with garbage.
|
||||
const range = getPartByteRange(4, MIN_PART_SIZE, 15 * MIB);
|
||||
expect(range.start).toBe(15 * MIB);
|
||||
expect(range.end).toBe(15 * MIB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadProgressPercent', () => {
|
||||
it('reports whole percent through the upload', () => {
|
||||
expect(getUploadProgressPercent(0, 200)).toBe(0);
|
||||
expect(getUploadProgressPercent(50, 200)).toBe(25);
|
||||
expect(getUploadProgressPercent(200, 200)).toBe(100);
|
||||
});
|
||||
|
||||
it('rounds to the nearest percent rather than truncating', () => {
|
||||
expect(getUploadProgressPercent(7, 1000)).toBe(1);
|
||||
expect(getUploadProgressPercent(4, 1000)).toBe(0);
|
||||
expect(getUploadProgressPercent(995, 1000)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMultipartProgressPercent', () => {
|
||||
it('adds up the bytes reported by every part', () => {
|
||||
expect(getMultipartProgressPercent([0, 0, 0], 300)).toBe(0);
|
||||
expect(getMultipartProgressPercent([100, 50, 0], 300)).toBe(50);
|
||||
expect(getMultipartProgressPercent([100, 100, 100], 300)).toBe(100);
|
||||
});
|
||||
|
||||
it('never reports past 100 when a retried part double-counts', () => {
|
||||
// A part that failed halfway and was retried has already reported those
|
||||
// bytes once; without the clamp the bar would run past the end of the track.
|
||||
expect(getMultipartProgressPercent([100, 100, 150], 300)).toBe(100);
|
||||
});
|
||||
|
||||
it('counts progress against the whole file, not the part', () => {
|
||||
expect(getMultipartProgressPercent([100, 0, 0], 300)).toBe(33);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
// Prisma's client errors are real classes, so `err.constructor.name` is what the
|
||||
// sanitiser branches on. Reproducing them as classes rather than as plain objects
|
||||
// with a `name` property is the only way to exercise the branch the way production
|
||||
// reaches it.
|
||||
class PrismaClientKnownRequestError extends Error {
|
||||
code: string;
|
||||
meta?: Record<string, unknown>;
|
||||
constructor(message: string, code: string, meta?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = 'PrismaClientKnownRequestError';
|
||||
this.code = code;
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
class PrismaClientValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PrismaClientValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
class PrismaClientInitializationError extends Error {
|
||||
errorCode: string;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PrismaClientInitializationError';
|
||||
this.errorCode = 'P1001';
|
||||
}
|
||||
}
|
||||
|
||||
// A Stripe SDK error, shaped the way the sanitiser detects it: a string `type`
|
||||
// alongside a numeric `statusCode`.
|
||||
class StripeCardError extends Error {
|
||||
type = 'StripeCardError';
|
||||
statusCode = 402;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StripeCardError';
|
||||
}
|
||||
}
|
||||
|
||||
// The kind of message a Prisma failure actually carries: the failing statement,
|
||||
// the table and column names, and the literal values from the WHERE clause. None
|
||||
// of this may reach a log sink.
|
||||
const LEAKY_PRISMA_MESSAGE = [
|
||||
'Invalid `prisma.user.findUnique()` invocation:',
|
||||
'Raw query failed. Code: `42P01`.',
|
||||
'SELECT "public"."User"."id", "public"."User"."passwordHash" FROM "public"."User"',
|
||||
'WHERE "public"."User"."email" = \'[email protected]\' LIMIT 1 OFFSET 0',
|
||||
].join('\n');
|
||||
|
||||
// Swallows the output as well as capturing it, so the suite stays quiet.
|
||||
function spyOnConsoleError() {
|
||||
return vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
}
|
||||
|
||||
let errorSpy: ReturnType<typeof spyOnConsoleError>;
|
||||
|
||||
function loggedPayload(): unknown {
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
return errorSpy.mock.calls[0]![1];
|
||||
}
|
||||
|
||||
function loggedText(): string {
|
||||
return JSON.stringify(loggedPayload() ?? null);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = spyOnConsoleError();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('logError', () => {
|
||||
describe('Prisma errors', () => {
|
||||
it('redacts a Prisma message carrying raw SQL down to the error code', () => {
|
||||
logError(
|
||||
'user lookup failed',
|
||||
new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002')
|
||||
);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'P2002',
|
||||
message: 'Database error [P2002]',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaks no fragment of the original SQL, table names or WHERE values', () => {
|
||||
logError(
|
||||
'user lookup failed',
|
||||
new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002')
|
||||
);
|
||||
|
||||
const text = loggedText();
|
||||
expect(text).not.toContain('SELECT');
|
||||
expect(text).not.toContain('passwordHash');
|
||||
expect(text).not.toContain('[email protected]');
|
||||
expect(text).not.toContain('prisma.user.findUnique');
|
||||
expect(text).not.toContain('"public"."User"');
|
||||
});
|
||||
|
||||
it('never logs the `meta` object, which repeats the offending field values', () => {
|
||||
const err = new PrismaClientKnownRequestError('Unique constraint failed', 'P2002', {
|
||||
target: ['email'],
|
||||
value: '[email protected]',
|
||||
});
|
||||
|
||||
logError('create failed', err);
|
||||
|
||||
expect(loggedText()).not.toContain('[email protected]');
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'P2002',
|
||||
message: 'Database error [P2002]',
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes UNKNOWN when the Prisma error carries no code', () => {
|
||||
logError('validation failed', new PrismaClientValidationError(LEAKY_PRISMA_MESSAGE));
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'UNKNOWN',
|
||||
message: 'Database error [UNKNOWN]',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts a Prisma initialization error, whose message embeds the database url', () => {
|
||||
const err = new PrismaClientInitializationError(
|
||||
"Can't reach database server at `postgresql://admin:[email protected]:5432`"
|
||||
);
|
||||
|
||||
logError('startup failed', err);
|
||||
|
||||
const text = loggedText();
|
||||
expect(text).not.toContain('hunter2');
|
||||
expect(text).not.toContain('db.internal');
|
||||
// `errorCode`, not `code`, so the string branch does not match it.
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'UNKNOWN',
|
||||
message: 'Database error [UNKNOWN]',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a non-string Prisma code rather than logging it', () => {
|
||||
const err = new PrismaClientKnownRequestError('boom', 'P2002');
|
||||
(err as unknown as Record<string, unknown>).code = 2002;
|
||||
|
||||
logError('create failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'UNKNOWN',
|
||||
message: 'Database error [UNKNOWN]',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the Prisma branch over the Stripe branch when an error matches both', () => {
|
||||
const err = new PrismaClientKnownRequestError(LEAKY_PRISMA_MESSAGE, 'P2002');
|
||||
const anyErr = err as unknown as Record<string, unknown>;
|
||||
anyErr.type = 'invalid_request_error';
|
||||
anyErr.statusCode = 400;
|
||||
|
||||
logError('ambiguous failure', err);
|
||||
|
||||
// If the ordering flipped, `message: err.message` would ship the SQL.
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'PrismaError',
|
||||
code: 'P2002',
|
||||
message: 'Database error [P2002]',
|
||||
});
|
||||
});
|
||||
|
||||
// Documents a real limitation rather than an intended behaviour: the branch
|
||||
// keys on the constructor name, so an error that only claims to be a Prisma
|
||||
// error through `err.name` (a re-thrown, deserialised or minified one) falls
|
||||
// through to the generic branch and its message is logged verbatim.
|
||||
it('does not redact an error that is Prisma only by its `name` property', () => {
|
||||
const err = new Error(LEAKY_PRISMA_MESSAGE);
|
||||
err.name = 'PrismaClientKnownRequestError';
|
||||
|
||||
logError('user lookup failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'Error', message: LEAKY_PRISMA_MESSAGE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stripe errors', () => {
|
||||
it('keeps the message and records the http status as the code', () => {
|
||||
logError('charge failed', new StripeCardError('Your card was declined.'));
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'StripeCardError',
|
||||
code: '402',
|
||||
message: 'Your card was declined.',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the SDK `type` field rather than the class name', () => {
|
||||
const err = new StripeCardError('No such customer: cus_123');
|
||||
(err as unknown as Record<string, unknown>).type = 'invalid_request_error';
|
||||
|
||||
logError('portal failed', err);
|
||||
|
||||
expect(loggedPayload()).toMatchObject({ type: 'invalid_request_error', code: '402' });
|
||||
});
|
||||
|
||||
it('falls through to the generic branch when statusCode is not numeric', () => {
|
||||
const err = new StripeCardError('Your card was declined.');
|
||||
(err as unknown as Record<string, unknown>).statusCode = '402';
|
||||
|
||||
logError('charge failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'StripeCardError',
|
||||
message: 'Your card was declined.',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls through to the generic branch when `type` is not a string', () => {
|
||||
const err = new StripeCardError('Your card was declined.');
|
||||
(err as unknown as Record<string, unknown>).type = 7;
|
||||
|
||||
logError('charge failed', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'StripeCardError',
|
||||
message: 'Your card was declined.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain errors', () => {
|
||||
it('logs the type and message of an ordinary Error', () => {
|
||||
logError('something broke', new Error('boom'));
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'Error', message: 'boom' });
|
||||
});
|
||||
|
||||
it('reports the subclass name as the type', () => {
|
||||
class UploadRejectedError extends Error {}
|
||||
|
||||
logError('upload failed', new UploadRejectedError('too large'));
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'UploadRejectedError', message: 'too large' });
|
||||
});
|
||||
|
||||
it('never includes the stack trace, which exposes absolute server paths', () => {
|
||||
const err = new Error('boom');
|
||||
err.stack = 'Error: boom\n at /srv/openframe/app/api/projects/route.ts:42:11';
|
||||
|
||||
logError('something broke', err);
|
||||
|
||||
expect(loggedPayload()).not.toHaveProperty('stack');
|
||||
expect(loggedText()).not.toContain('/srv/openframe');
|
||||
});
|
||||
|
||||
it('does not include a `cause`, which can wrap the original driver error', () => {
|
||||
const err = new Error('wrapped', { cause: new Error(LEAKY_PRISMA_MESSAGE) });
|
||||
|
||||
logError('something broke', err);
|
||||
|
||||
expect(loggedPayload()).toEqual({ type: 'Error', message: 'wrapped' });
|
||||
expect(loggedText()).not.toContain('SELECT');
|
||||
});
|
||||
|
||||
it('handles a TypeError thrown by the runtime itself', () => {
|
||||
logError('bad access', new TypeError("Cannot read properties of undefined (reading 'id')"));
|
||||
|
||||
expect(loggedPayload()).toEqual({
|
||||
type: 'TypeError',
|
||||
message: "Cannot read properties of undefined (reading 'id')",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-Error values', () => {
|
||||
// These were constructed by the caller, so they are already whatever the
|
||||
// caller decided to expose and are passed through untouched.
|
||||
it.each([
|
||||
['a string', 'plain failure text'],
|
||||
['a number', 42],
|
||||
['a boolean', false],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('passes %s through unchanged', (_label, value) => {
|
||||
logError('context', value);
|
||||
|
||||
expect(loggedPayload()).toBe(value);
|
||||
});
|
||||
|
||||
it('passes a structured object through by reference', () => {
|
||||
const payload = { status: 502, provider: 'bunny' };
|
||||
|
||||
logError('upstream refused', payload);
|
||||
|
||||
expect(loggedPayload()).toBe(payload);
|
||||
});
|
||||
|
||||
it('passes an Error-shaped plain object through, since it is not an Error instance', () => {
|
||||
const payload = { name: 'PrismaClientKnownRequestError', message: LEAKY_PRISMA_MESSAGE };
|
||||
|
||||
logError('context', payload);
|
||||
|
||||
expect(loggedPayload()).toBe(payload);
|
||||
});
|
||||
});
|
||||
|
||||
it('writes to console.error with the context string first and the payload second', () => {
|
||||
logError('projects.POST failed', new Error('boom'));
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy.mock.calls[0]).toHaveLength(2);
|
||||
expect(errorSpy.mock.calls[0]![0]).toBe('projects.POST failed');
|
||||
});
|
||||
|
||||
it('returns undefined rather than the sanitized payload', () => {
|
||||
expect(logError('context', new Error('boom'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
// lib/notifications.ts is stubbed wholesale in tests/setup/api.ts so the API
|
||||
// suite never fans out to Telegram or SMTP. This file stubs the boundaries
|
||||
// instead (Prisma, global fetch, nodemailer) and asserts on the decision the
|
||||
// module actually owns: who receives a notification and who does not.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type NotificationEvent, notifyProjectOwner, notifyUsers } from '@/lib/notifications';
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
notificationSetting: { findMany: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
|
||||
|
||||
const mail = vi.hoisted(() => {
|
||||
const sendMail = vi.fn<
|
||||
(message: {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}) => Promise<{ messageId: string }>
|
||||
>(async () => ({ messageId: 'unit-test-message-id' }));
|
||||
const createTransport = vi.fn(() => ({ sendMail, verify: vi.fn(async () => true) }));
|
||||
return { sendMail, createTransport };
|
||||
});
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: { createTransport: mail.createTransport },
|
||||
createTransport: mail.createTransport,
|
||||
}));
|
||||
|
||||
type Settings = Parameters<typeof settingsRow>[0];
|
||||
|
||||
function settingsRow(overrides: {
|
||||
userId?: string;
|
||||
email?: string | null;
|
||||
emailEnabled?: boolean;
|
||||
telegramEnabled?: boolean;
|
||||
telegramChatId?: string | null;
|
||||
timezone?: string;
|
||||
onNewVideo?: boolean;
|
||||
onNewVersion?: boolean;
|
||||
onNewComment?: boolean;
|
||||
onNewReply?: boolean;
|
||||
onApprovalEvents?: boolean;
|
||||
}) {
|
||||
const userId = overrides.userId ?? 'user-1';
|
||||
return {
|
||||
userId,
|
||||
emailEnabled: overrides.emailEnabled ?? true,
|
||||
telegramEnabled: overrides.telegramEnabled ?? true,
|
||||
telegramChatId: overrides.telegramChatId === undefined ? 'chat-1' : overrides.telegramChatId,
|
||||
timezone: overrides.timezone ?? 'UTC',
|
||||
onNewVideo: overrides.onNewVideo ?? true,
|
||||
onNewVersion: overrides.onNewVersion ?? true,
|
||||
onNewComment: overrides.onNewComment ?? true,
|
||||
onNewReply: overrides.onNewReply ?? true,
|
||||
onApprovalEvents: overrides.onApprovalEvents ?? true,
|
||||
user: { email: overrides.email === undefined ? `${userId}@example.com` : overrides.email },
|
||||
};
|
||||
}
|
||||
|
||||
function recipients(...rows: ReturnType<typeof settingsRow>[]): void {
|
||||
dbMock.notificationSetting.findMany.mockResolvedValue(rows);
|
||||
}
|
||||
|
||||
const COMMENT_EVENT: NotificationEvent = {
|
||||
type: 'new_comment',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
commentAuthor: 'Ada',
|
||||
commentText: 'The cut at 0:12 is too fast',
|
||||
timestamp: '0:12',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
};
|
||||
|
||||
const VIDEO_EVENT: NotificationEvent = {
|
||||
type: 'new_video',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
addedBy: 'Ada',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
};
|
||||
|
||||
const EVENTS: Record<string, NotificationEvent> = {
|
||||
new_video: VIDEO_EVENT,
|
||||
new_version: {
|
||||
type: 'new_version',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
addedBy: 'Ada',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
new_comment: COMMENT_EVENT,
|
||||
new_reply: {
|
||||
type: 'new_reply',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
replyAuthor: 'Grace',
|
||||
replyText: 'Agreed',
|
||||
parentAuthor: 'Ada',
|
||||
timestamp: '0:12',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_requested: {
|
||||
type: 'approval_requested',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
requestedBy: 'Ada',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_action: {
|
||||
type: 'approval_action',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
actorName: 'Grace',
|
||||
action: 'approved',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_completed: {
|
||||
type: 'approval_completed',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
approvedByCount: 2,
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
approval_rejected: {
|
||||
type: 'approval_rejected',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
rejectedBy: 'Grace',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
/** The parsed body of the Nth Telegram call. */
|
||||
function telegramPayload(index = 0): Record<string, unknown> {
|
||||
return JSON.parse(String(fetchMock.mock.calls[index][1].body));
|
||||
}
|
||||
|
||||
function sentMail(index = 0): { from: string; to: string; subject: string; html: string } {
|
||||
return mail.sendMail.mock.calls[index][0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.notificationSetting.findMany.mockReset();
|
||||
dbMock.notificationSetting.findMany.mockResolvedValue([]);
|
||||
mail.sendMail.mockReset();
|
||||
mail.sendMail.mockResolvedValue({ messageId: 'unit-test-message-id' });
|
||||
mail.createTransport.mockClear();
|
||||
|
||||
fetchMock = vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
vi.stubEnv('TELEGRAM_BOT_TOKEN', 'bot-token-unit');
|
||||
vi.stubEnv('SMTP_HOST', 'smtp.example.com');
|
||||
vi.stubEnv('SMTP_PORT', '587');
|
||||
vi.stubEnv('SMTP_USER', 'smtp-user');
|
||||
vi.stubEnv('SMTP_PASSWORD', 'smtp-password');
|
||||
vi.stubEnv('SMTP_FROM', undefined);
|
||||
vi.stubEnv('EMAIL_FROM', undefined);
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('choosing the recipient list', () => {
|
||||
it('does not query the database when no recipient was named', async () => {
|
||||
await notifyUsers([], COMMENT_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not query the database when every recipient id is empty', async () => {
|
||||
await notifyUsers(['', ''], COMMENT_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates the recipient list and drops empty ids before querying', async () => {
|
||||
await notifyUsers(['user-1', 'user-1', '', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: { in: ['user-1', 'user-2'] } },
|
||||
include: { user: { select: { email: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
// A user with no settings row is simply absent from findMany's result, so the
|
||||
// fan-out silently skips them. That is the current contract.
|
||||
it('sends nothing to a named user who has no notification settings row', async () => {
|
||||
recipients();
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reaches every recipient that does have a row', async () => {
|
||||
recipients(settingsRow({ userId: 'user-1' }), settingsRow({ userId: 'user-2' }));
|
||||
|
||||
await notifyUsers(['user-1', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(sentMail(0).to).toBe('[email protected]');
|
||||
expect(sentMail(1).to).toBe('[email protected]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-user event settings', () => {
|
||||
it.each([
|
||||
['new_video', 'onNewVideo'],
|
||||
['new_version', 'onNewVersion'],
|
||||
['new_comment', 'onNewComment'],
|
||||
['new_reply', 'onNewReply'],
|
||||
['approval_requested', 'onApprovalEvents'],
|
||||
['approval_action', 'onApprovalEvents'],
|
||||
['approval_completed', 'onApprovalEvents'],
|
||||
['approval_rejected', 'onApprovalEvents'],
|
||||
] as const)('sends a %s event only when %s is on', async (eventType, flag) => {
|
||||
recipients(settingsRow({ [flag]: false } as Settings));
|
||||
await notifyUsers(['user-1'], EVENTS[eventType]);
|
||||
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
recipients(settingsRow({ [flag]: true } as Settings));
|
||||
await notifyUsers(['user-1'], EVENTS[eventType]);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('turning off one event type leaves the others alone', async () => {
|
||||
recipients(settingsRow({ onNewComment: false }));
|
||||
|
||||
await notifyUsers(['user-1'], VIDEO_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends nothing for an event type the settings do not map', async () => {
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], { type: 'video_deleted' } as unknown as NotificationEvent);
|
||||
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel selection', () => {
|
||||
it('still sends the email to a user who has no Telegram chat id', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: true, telegramChatId: null }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(sentMail(0).to).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('still sends the email when the deployment has no Telegram bot token', async () => {
|
||||
vi.stubEnv('TELEGRAM_BOT_TOKEN', undefined);
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips Telegram for a user who turned it off', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips email for a user who turned it off but still sends Telegram', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips email for a user with no address on file', async () => {
|
||||
recipients(settingsRow({ email: null }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends nothing at all to a user with both channels off', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false, telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mail.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not build an SMTP transport when SMTP is unconfigured', async () => {
|
||||
vi.stubEnv('SMTP_HOST', undefined);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.createTransport).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses an implicit TLS connection only on port 465', async () => {
|
||||
vi.stubEnv('SMTP_PORT', '465');
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: 'smtp.example.com', port: 465, secure: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one failing recipient does not stop the rest', () => {
|
||||
it('keeps delivering after a recipient whose email send rejects', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mail.sendMail.mockImplementation(async (message) => {
|
||||
if (message.to === '[email protected]') throw new Error('mailbox full');
|
||||
return { messageId: 'ok' };
|
||||
});
|
||||
recipients(settingsRow({ userId: 'user-1' }), settingsRow({ userId: 'user-2' }));
|
||||
|
||||
await notifyUsers(['user-1', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(sentMail(1).to).toBe('[email protected]');
|
||||
expect(logged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps delivering after a recipient whose Telegram call rejects', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
fetchMock.mockRejectedValueOnce(new Error('telegram unreachable'));
|
||||
recipients(settingsRow({ userId: 'user-1' }), settingsRow({ userId: 'user-2' }));
|
||||
|
||||
await notifyUsers(['user-1', 'user-2'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(logged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still emails a user whose own Telegram delivery failed', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 403, text: async () => 'bot blocked' });
|
||||
recipients(settingsRow({}));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves rather than throwing when the settings lookup fails', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
dbMock.notificationSetting.findMany.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
await expect(notifyUsers(['user-1'], COMMENT_EVENT)).resolves.toBeUndefined();
|
||||
expect(logged).toHaveBeenCalledWith('Notification dispatch failed:', {
|
||||
type: 'Error',
|
||||
message: 'connection refused',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves rather than throwing when a recipient has a malformed settings row', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// `user` is missing, so reading settings.user.email throws inside the map.
|
||||
dbMock.notificationSetting.findMany.mockResolvedValue([
|
||||
{ ...settingsRow({}), user: undefined },
|
||||
settingsRow({ userId: 'user-2' }),
|
||||
]);
|
||||
|
||||
await expect(notifyUsers(['user-1', 'user-2'], COMMENT_EVENT)).resolves.toBeUndefined();
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(sentMail(0).to).toBe('[email protected]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Telegram message', () => {
|
||||
it('posts to the bot sendMessage endpoint with the chat id and preview disabled', async () => {
|
||||
recipients(settingsRow({ telegramChatId: 'chat-42', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'https://api.telegram.org/botbot-token-unit/sendMessage'
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(telegramPayload()).toMatchObject({
|
||||
chat_id: 'chat-42',
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the deep link as an inline button', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(telegramPayload().reply_markup).toEqual({
|
||||
inline_keyboard: [[{ text: 'View Comment', url: 'https://app.example.com/watch/video-1' }]],
|
||||
});
|
||||
});
|
||||
|
||||
// Telegram rejects an inline keyboard whose url is not https, which would
|
||||
// fail the whole message rather than just the button.
|
||||
it('omits the button when the deep link is not https', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, url: 'http://localhost:3000/watch/video-1' });
|
||||
|
||||
expect(telegramPayload().reply_markup).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries the project, video, author and comment body in the text', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Project: Launch Film');
|
||||
expect(text).toContain('Video: Teaser');
|
||||
expect(text).toContain('By: Ada at 0:12');
|
||||
expect(text).toContain('"The cut at 0:12 is too fast"');
|
||||
// The url lives on the button, not in the body.
|
||||
expect(text).not.toContain('https://app.example.com');
|
||||
});
|
||||
|
||||
it('truncates a long comment body to 200 characters', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, commentText: 'x'.repeat(250) });
|
||||
|
||||
expect(String(telegramPayload().text)).toContain(`"${'x'.repeat(200)}..."`);
|
||||
});
|
||||
|
||||
it('leaves a body at exactly 200 characters untruncated', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, commentText: 'x'.repeat(200) });
|
||||
|
||||
expect(String(telegramPayload().text)).toContain(`"${'x'.repeat(200)}"`);
|
||||
});
|
||||
|
||||
it('omits the optional note block when an approval carries no note', async () => {
|
||||
// Rendered twice, once without a note and once with, so the assertion is
|
||||
// about the note block itself rather than about quotation marks in general:
|
||||
// the two bodies have to differ by exactly that block and nothing else. The
|
||||
// clock is frozen because the body carries a minute-precision timestamp, and
|
||||
// a rollover between the two calls would make them differ for an unrelated
|
||||
// reason.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z'));
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
// Spelled out rather than taken from EVENTS so that `note` can be added to a
|
||||
// copy: EVENTS is typed as the whole NotificationEvent union, and only the
|
||||
// approval variants carry a note.
|
||||
const rejected = {
|
||||
type: 'approval_rejected',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
rejectedBy: 'Grace',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
} satisfies NotificationEvent;
|
||||
|
||||
await notifyUsers(['user-1'], rejected);
|
||||
await notifyUsers(['user-1'], { ...rejected, note: 'colour is off' });
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(String(telegramPayload(1).text)).toBe(
|
||||
`${String(telegramPayload(0).text)}\n\n"colour is off"`
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the note when an approval carries one', async () => {
|
||||
recipients(settingsRow({ emailEnabled: false }));
|
||||
|
||||
const withNote: NotificationEvent = {
|
||||
type: 'approval_rejected',
|
||||
projectName: 'Launch Film',
|
||||
videoTitle: 'Teaser',
|
||||
versionLabel: 'v2',
|
||||
rejectedBy: 'Grace',
|
||||
note: 'colour is off',
|
||||
url: 'https://app.example.com/watch/video-1',
|
||||
};
|
||||
|
||||
await notifyUsers(['user-1'], withNote);
|
||||
|
||||
expect(String(telegramPayload().text)).toContain('"colour is off"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the email message', () => {
|
||||
it.each([
|
||||
['new_video', '[OpenFrame] New video in Launch Film: Teaser'],
|
||||
['new_version', '[OpenFrame] New version of Teaser in Launch Film'],
|
||||
['new_comment', '[OpenFrame] New comment on Teaser'],
|
||||
['new_reply', '[OpenFrame] Grace replied on Teaser'],
|
||||
['approval_requested', '[OpenFrame] Approval requested for v2 in Launch Film'],
|
||||
['approval_action', '[OpenFrame] Approval approved by Grace'],
|
||||
['approval_completed', '[OpenFrame] Approval completed for v2'],
|
||||
['approval_rejected', '[OpenFrame] Approval rejected by Grace'],
|
||||
])('subjects a %s event as %s', async (eventType, subject) => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], EVENTS[eventType]);
|
||||
|
||||
expect(sentMail(0).subject).toBe(subject);
|
||||
});
|
||||
|
||||
it('falls back to the product address when no from address is configured', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(sentMail(0).from).toBe('OpenFrame <[email protected]>');
|
||||
});
|
||||
|
||||
it('prefers SMTP_FROM over EMAIL_FROM', async () => {
|
||||
vi.stubEnv('SMTP_FROM', 'A <[email protected]>');
|
||||
vi.stubEnv('EMAIL_FROM', 'B <[email protected]>');
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(sentMail(0).from).toBe('A <[email protected]>');
|
||||
});
|
||||
|
||||
it('escapes user-supplied text so a project name cannot inject markup', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], {
|
||||
...COMMENT_EVENT,
|
||||
projectName: '<script>alert(1)</script>',
|
||||
commentText: '<img src=x onerror=alert(1)>',
|
||||
});
|
||||
|
||||
const { html } = sentMail(0);
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('links the footer at the configured app url', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
expect(sentMail(0).html).toContain('https://app.example.com/settings');
|
||||
});
|
||||
|
||||
it('truncates a long comment body to 300 characters', async () => {
|
||||
recipients(settingsRow({ telegramEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], { ...COMMENT_EVENT, commentText: 'x'.repeat(400) });
|
||||
|
||||
expect(sentMail(0).html).toContain(`${'x'.repeat(300)}...`);
|
||||
expect(sentMail(0).html).not.toContain('x'.repeat(301));
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestamp rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-15T23:30:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the time in the recipient timezone', async () => {
|
||||
recipients(settingsRow({ timezone: 'Europe/Istanbul', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
// 23:30 UTC is 02:30 the next day in Istanbul (UTC+3). The date and the
|
||||
// time are asserted separately because the separator between them is an
|
||||
// ICU detail that differs between runtimes.
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Jan 16, 2026');
|
||||
expect(text).toContain('2:30 AM');
|
||||
});
|
||||
|
||||
it('falls back to UTC for a timezone the runtime rejects', async () => {
|
||||
recipients(settingsRow({ timezone: 'Mars/Olympus', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Jan 15, 2026');
|
||||
expect(text).toContain('11:30 PM');
|
||||
});
|
||||
|
||||
it('falls back to UTC when the row stores an empty timezone', async () => {
|
||||
recipients(settingsRow({ timezone: '', emailEnabled: false }));
|
||||
|
||||
await notifyUsers(['user-1'], COMMENT_EVENT);
|
||||
|
||||
const text = String(telegramPayload().text);
|
||||
expect(text).toContain('Jan 15, 2026');
|
||||
expect(text).toContain('11:30 PM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyProjectOwner', () => {
|
||||
it('fans out to the single owner id', async () => {
|
||||
recipients(settingsRow({ userId: 'owner-1' }));
|
||||
|
||||
await notifyProjectOwner('owner-1', VIDEO_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: { in: ['owner-1'] } },
|
||||
include: { user: { select: { email: true } } },
|
||||
});
|
||||
expect(mail.sendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when the owner id is empty', async () => {
|
||||
await notifyProjectOwner('', VIDEO_EVENT);
|
||||
|
||||
expect(dbMock.notificationSetting.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,496 @@
|
||||
// Unit tests for lib/r2-media-proxy.ts, the single helper behind every media
|
||||
// proxy route (`/api/upload/image/[filename]`, `/api/upload/audio/[filename]`,
|
||||
// `/api/upload/video/[filename]`).
|
||||
//
|
||||
// The routes decide *who* may read an object; this module decides *what* comes
|
||||
// back. Everything interesting it does is invisible from the route tests, which
|
||||
// stub this function out at its boundary: the Range and If-Range plumbing, the
|
||||
// content-type fallback, the 404/416/500 mapping of S3 errors, and the header
|
||||
// set. All of it is exercised here against a fake `r2Client`, so no test in this
|
||||
// file speaks S3.
|
||||
//
|
||||
// The seam is `@/lib/r2`. Mocking it rather than the AWS SDK keeps the real
|
||||
// GetObjectCommand in play, which is what lets the assertions below read the
|
||||
// exact command input the module built.
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/r2', () => ({
|
||||
r2Client: { send: sendMock },
|
||||
R2_BUCKET_NAME: 'test-bucket',
|
||||
}));
|
||||
|
||||
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
|
||||
|
||||
const BASE_OPTIONS = {
|
||||
key: 'images/photo.png',
|
||||
fallbackContentType: 'image/png',
|
||||
cacheControl: 'private, no-store',
|
||||
internalErrorMessage: 'Failed to retrieve image',
|
||||
};
|
||||
|
||||
/** A GetObjectCommandOutput carrying `text` as a Node stream, the shape the SDK returns. */
|
||||
function objectWith(overrides: Record<string, unknown> = {}, text = 'file-bytes') {
|
||||
return {
|
||||
Body: Readable.from([Buffer.from(text)]),
|
||||
ContentType: 'image/png',
|
||||
ContentLength: text.length,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** An error shaped like the ones @aws-sdk/client-s3 throws. */
|
||||
function s3Error(props: { name?: string; Code?: string; httpStatusCode?: number }): Error {
|
||||
const error = new Error('s3 failure');
|
||||
if (props.name) error.name = props.name;
|
||||
return Object.assign(error, {
|
||||
Code: props.Code,
|
||||
$metadata: { httpStatusCode: props.httpStatusCode },
|
||||
});
|
||||
}
|
||||
|
||||
/** The input of the nth GetObjectCommand handed to r2Client.send(). */
|
||||
function commandInput(call = 0): Record<string, unknown> {
|
||||
const command = sendMock.mock.calls[call]?.[0] as GetObjectCommand;
|
||||
return command.input as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function request(headers: Record<string, string> = {}): Request {
|
||||
return new Request('http://localhost:3000/api/upload/image/photo.png', { headers });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset();
|
||||
// logError() writes to console.error on the failure paths. Silenced so the
|
||||
// expected-error tests do not print, and so the last case can assert on it.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The object key
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('key handling', () => {
|
||||
it('sends the caller-supplied key and the configured bucket verbatim', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(commandInput()).toMatchObject({ Bucket: 'test-bucket', Key: 'images/photo.png' });
|
||||
});
|
||||
|
||||
// Pinning the absence of validation, not endorsing it. This module applies no
|
||||
// normalisation and no prefix check to `key`, so a caller that builds one from
|
||||
// unvalidated input hands the traversal straight to S3. Today all three call
|
||||
// sites gate the filename on a UUID regex first, which is the only reason this
|
||||
// is not reachable. If a fourth route ever skips that regex, nothing in this
|
||||
// module will stop it. See the report accompanying this suite.
|
||||
it('passes a traversal-shaped key through untouched', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
key: 'images/../../etc/passwd',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(commandInput().Key).toBe('images/../../etc/passwd');
|
||||
});
|
||||
|
||||
it('sends no Range or conditional fields when the request has no range header', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
const input = commandInput();
|
||||
expect(input.Range).toBeUndefined();
|
||||
expect(input.IfMatch).toBeUndefined();
|
||||
expect(input.IfUnmodifiedSince).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The success response
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('a successful read', () => {
|
||||
it('returns 200 with the object bytes', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({}, 'hello-media'));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe('hello-media');
|
||||
});
|
||||
|
||||
it('prefers the content type R2 reports over the fallback', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentType: 'image/webp' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
fallbackContentType: 'image/png',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('image/webp');
|
||||
});
|
||||
|
||||
// R2 stores objects uploaded without an explicit type as
|
||||
// application/octet-stream. Serving that back would make the browser download
|
||||
// the file instead of rendering it, so the route's extension-derived guess wins.
|
||||
it('falls back to the caller content type when R2 reports application/octet-stream', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentType: 'application/octet-stream' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
fallbackContentType: 'audio/webm',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('audio/webm');
|
||||
});
|
||||
|
||||
it('falls back to the caller content type when R2 reports none at all', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentType: undefined }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
fallbackContentType: 'video/mp4',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('passes through length, etag and last-modified from the object', async () => {
|
||||
sendMock.mockResolvedValue(
|
||||
objectWith({
|
||||
ContentLength: 9,
|
||||
ETag: '"abc123"',
|
||||
LastModified: new Date(Date.UTC(2026, 0, 2, 3, 4, 5)),
|
||||
})
|
||||
);
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.headers.get('content-length')).toBe('9');
|
||||
expect(response.headers.get('etag')).toBe('"abc123"');
|
||||
expect(response.headers.get('last-modified')).toBe('Fri, 02 Jan 2026 03:04:05 GMT');
|
||||
});
|
||||
|
||||
it('omits headers R2 did not report rather than sending empty ones', async () => {
|
||||
sendMock.mockResolvedValue(
|
||||
objectWith({ ContentLength: undefined, ETag: undefined, LastModified: undefined })
|
||||
);
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.headers.has('etag')).toBe(false);
|
||||
expect(response.headers.has('last-modified')).toBe(false);
|
||||
});
|
||||
|
||||
// nosniff and `inline` are what keep a stored .png that is really HTML from
|
||||
// being rendered as a document in the user's origin.
|
||||
it('always sets nosniff, inline disposition and the caller cache policy', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
cacheControl: 'private, max-age=3600',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(response.headers.get('content-disposition')).toBe('inline');
|
||||
expect(response.headers.get('cache-control')).toBe('private, max-age=3600');
|
||||
expect(response.headers.get('accept-ranges')).toBe('bytes');
|
||||
});
|
||||
|
||||
it('applies extraHeaders on top, overriding what the module set', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
extraHeaders: {
|
||||
'Content-Security-Policy': "default-src 'none'; sandbox",
|
||||
'Content-Disposition': 'attachment',
|
||||
},
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox");
|
||||
expect(response.headers.get('content-disposition')).toBe('attachment');
|
||||
});
|
||||
|
||||
it('accepts a web ReadableStream body as well as a Node stream', async () => {
|
||||
sendMock.mockResolvedValue({
|
||||
ContentType: 'image/png',
|
||||
Body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('web-stream-bytes'));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe('web-stream-bytes');
|
||||
});
|
||||
|
||||
it('returns 500 when the object came back with no body to stream', async () => {
|
||||
sendMock.mockResolvedValue({ ContentType: 'image/png', Body: undefined });
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'Empty file' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range requests
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video scrubbing depends entirely on this path: the browser asks for a byte
|
||||
// window and expects 206 plus a Content-Range back. A regression that dropped
|
||||
// the range and answered 200 with the whole file would still "work" in a
|
||||
// download and break seeking.
|
||||
describe('range requests', () => {
|
||||
it('forwards the range header and answers 206 when R2 returns a partial object', async () => {
|
||||
sendMock.mockResolvedValue(
|
||||
objectWith({ ContentRange: 'bytes 0-4/100', ContentLength: 5 }, 'first')
|
||||
);
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4' }),
|
||||
});
|
||||
|
||||
expect(commandInput().Range).toBe('bytes=0-4');
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get('content-range')).toBe('bytes 0-4/100');
|
||||
await expect(response.text()).resolves.toBe('first');
|
||||
});
|
||||
|
||||
it('answers 200 when a range was asked for but R2 returned the whole object', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: undefined }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('turns an unsatisfiable range into an empty 416', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ name: 'InvalidRange' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
cacheControl: 'private, max-age=3600',
|
||||
request: request({ range: 'bytes=99999-' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(416);
|
||||
expect(response.headers.get('accept-ranges')).toBe('bytes');
|
||||
expect(response.headers.get('cache-control')).toBe('private, max-age=3600');
|
||||
await expect(response.text()).resolves.toBe('');
|
||||
});
|
||||
|
||||
it('recognises an unsatisfiable range reported only as HTTP 416', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ httpStatusCode: 416 }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=99999-' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(416);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// If-Range
|
||||
// ---------------------------------------------------------------------------
|
||||
// If-Range asks "give me this window, but only if the file has not changed since
|
||||
// I started". S3 has no If-Range, so the module translates it into IfMatch or
|
||||
// IfUnmodifiedSince and handles the 412 itself.
|
||||
describe('if-range handling', () => {
|
||||
it('translates a strong etag into IfMatch', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: 'bytes 0-4/100' }));
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': '"abc123"' }),
|
||||
});
|
||||
|
||||
expect(commandInput().IfMatch).toBe('"abc123"');
|
||||
expect(commandInput().IfUnmodifiedSince).toBeUndefined();
|
||||
});
|
||||
|
||||
it('translates an HTTP date into IfUnmodifiedSince', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: 'bytes 0-4/100' }));
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': 'Fri, 02 Jan 2026 03:04:05 GMT' }),
|
||||
});
|
||||
|
||||
expect(commandInput().IfMatch).toBeUndefined();
|
||||
expect((commandInput().IfUnmodifiedSince as Date).toUTCString()).toBe(
|
||||
'Fri, 02 Jan 2026 03:04:05 GMT'
|
||||
);
|
||||
});
|
||||
|
||||
// A weak validator (W/"...") cannot be used for byte-range equivalence, and
|
||||
// the token is not a date either, so neither condition is attachable.
|
||||
it('ignores a weak etag rather than sending it as IfMatch', async () => {
|
||||
sendMock.mockResolvedValue(objectWith({ ContentRange: 'bytes 0-4/100' }));
|
||||
|
||||
await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': 'W/"abc123"' }),
|
||||
});
|
||||
|
||||
expect(commandInput().IfMatch).toBeUndefined();
|
||||
expect(commandInput().IfUnmodifiedSince).toBeUndefined();
|
||||
expect(commandInput().Range).toBe('bytes=0-4');
|
||||
});
|
||||
|
||||
it('ignores if-range entirely when the request carries no range', async () => {
|
||||
sendMock.mockResolvedValue(objectWith());
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request({ 'if-range': '"abc123"' }) });
|
||||
|
||||
expect(commandInput().IfMatch).toBeUndefined();
|
||||
});
|
||||
|
||||
// The whole point of If-Range: when the validator no longer matches, the client
|
||||
// wants the full object back, not an error. S3 answers 412; the module retries
|
||||
// without the range and returns 200.
|
||||
it('retries without the range and returns the full object on a 412', async () => {
|
||||
sendMock
|
||||
.mockRejectedValueOnce(s3Error({ httpStatusCode: 412 }))
|
||||
.mockResolvedValueOnce(objectWith({ ContentRange: undefined }, 'whole-file'));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4', 'if-range': '"stale-etag"' }),
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(commandInput(1).Range).toBeUndefined();
|
||||
expect(commandInput(1).IfMatch).toBeUndefined();
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe('whole-file');
|
||||
});
|
||||
|
||||
// Without a conditional attached there is nothing to fall back to, so a 412
|
||||
// is just an error like any other.
|
||||
it('does not retry a 412 that arrived without an if-range', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ httpStatusCode: 412 }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
request: request({ range: 'bytes=0-4' }),
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('reports the object as gone when the retry after a 412 finds nothing', async () => {
|
||||
sendMock
|
||||
.mockRejectedValueOnce(s3Error({ httpStatusCode: 412 }))
|
||||
.mockRejectedValueOnce(s3Error({ name: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
notFoundLabel: 'Audio',
|
||||
request: request({ range: 'bytes=0-4', 'if-range': '"stale-etag"' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'Audio not found' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Missing objects and failures
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('a missing object', () => {
|
||||
it('returns 404 labelled with the caller resource name', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ name: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
notFoundLabel: 'Image',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'Image not found' });
|
||||
});
|
||||
|
||||
it('defaults the label to File when the caller gave none', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ name: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'File not found' });
|
||||
});
|
||||
|
||||
// Some S3-compatible backends report the condition as a `Code` field or as a
|
||||
// bare 404 rather than through the error name.
|
||||
it('recognises NoSuchKey reported as a Code field', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ Code: 'NoSuchKey' }));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('recognises a missing object reported only as HTTP 404', async () => {
|
||||
sendMock.mockRejectedValue(s3Error({ httpStatusCode: 404 }));
|
||||
|
||||
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unexpected storage failure', () => {
|
||||
it('returns 500 with the caller message and never the underlying error', async () => {
|
||||
sendMock.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.1:9000'));
|
||||
|
||||
const response = await proxyR2MediaObject({
|
||||
...BASE_OPTIONS,
|
||||
internalErrorMessage: 'Failed to load video',
|
||||
request: request(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = (await response.json()) as { error: string };
|
||||
expect(body.error).toBe('Failed to load video');
|
||||
expect(body.error).not.toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('logs the failure through logError so it reaches the sanitising sink', async () => {
|
||||
sendMock.mockRejectedValue(new Error('bucket exploded'));
|
||||
|
||||
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('Error proxying R2 object:', {
|
||||
type: 'Error',
|
||||
message: 'bucket exploded',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
createR2UploadToken,
|
||||
parseR2UploadToken,
|
||||
verifyR2UploadToken,
|
||||
type R2UploadTokenSubject,
|
||||
} from '@/lib/r2-upload-token';
|
||||
|
||||
const SECRET = 'r2-upload-token-test-secret';
|
||||
const OTHER_SECRET = 'a-completely-different-secret';
|
||||
const NOW = new Date('2026-01-15T12:00:00.000Z');
|
||||
const NOW_SECONDS = Math.floor(NOW.getTime() / 1000);
|
||||
const ONE_HOUR = 60 * 60;
|
||||
|
||||
const SUBJECT = {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
objectKey: 'projects/project-1/videos/video-1/source.mp4',
|
||||
sessionId: 'session-1',
|
||||
tokenId: 'token-1',
|
||||
thumbnailObjectKey: 'projects/project-1/videos/video-1/thumb.jpg',
|
||||
} satisfies R2UploadTokenSubject & {
|
||||
sessionId: string;
|
||||
tokenId: string;
|
||||
thumbnailObjectKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mints a token over an arbitrary payload with a valid signature. Signatures are
|
||||
* never hardcoded here: they depend on the secret, so every expectation is about
|
||||
* behaviour. This exists only to reach the payload-shape checks, which a forged
|
||||
* signature can never get past.
|
||||
*/
|
||||
function signArbitrary(payload: unknown, secret = SECRET): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs raw JSON text rather than an object. JSON.stringify cannot emit a
|
||||
* non-finite number, so this is the only way to hand verify() a payload whose
|
||||
* `iat` or `exp` parses back as Infinity: a decimal exponent that overflows to
|
||||
* it, which JSON.parse accepts and turns into Infinity.
|
||||
*/
|
||||
function signRawJson(json: string, secret = SECRET): string {
|
||||
const encoded = Buffer.from(json, 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
function wellFormedPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
typ: 'r2-upload',
|
||||
uid: SUBJECT.userId,
|
||||
pid: SUBJECT.projectId,
|
||||
key: SUBJECT.objectKey,
|
||||
sid: SUBJECT.sessionId,
|
||||
jti: SUBJECT.tokenId,
|
||||
tkey: SUBJECT.thumbnailObjectKey,
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', SECRET);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('createR2UploadToken', () => {
|
||||
it('produces a two-part token separated by a dot', () => {
|
||||
expect(createR2UploadToken(SUBJECT).split('.')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('encodes the subject and the issue and expiry times into the payload', () => {
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
||||
|
||||
expect(payload).toEqual({
|
||||
typ: 'r2-upload',
|
||||
uid: 'user-1',
|
||||
pid: 'project-1',
|
||||
key: 'projects/project-1/videos/video-1/source.mp4',
|
||||
sid: 'session-1',
|
||||
jti: 'token-1',
|
||||
tkey: 'projects/project-1/videos/video-1/thumb.jpg',
|
||||
iat: NOW_SECONDS,
|
||||
exp: NOW_SECONDS + ONE_HOUR,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to a one hour lifetime', () => {
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
||||
|
||||
expect(payload!.exp - payload!.iat).toBe(3600);
|
||||
});
|
||||
|
||||
it('honours an explicit ttl', () => {
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT, 90));
|
||||
|
||||
expect(payload!.exp - payload!.iat).toBe(90);
|
||||
});
|
||||
|
||||
it('uses base64url, so the token survives a query string unescaped', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||
expect(encodeURIComponent(token)).toBe(token);
|
||||
});
|
||||
|
||||
it('prefers R2_UPLOAD_TOKEN_SECRET over NEXTAUTH_SECRET', () => {
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
// Verifying with only NEXTAUTH_SECRET available must fail, which it can only
|
||||
// do if the dedicated variable was the one that signed.
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to NEXTAUTH_SECRET when the dedicated secret is unset', () => {
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyR2UploadToken(createR2UploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to mint a token when no secret is configured at all', () => {
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
expect(() => createR2UploadToken(SUBJECT)).toThrow(
|
||||
'Missing R2_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyR2UploadToken', () => {
|
||||
it('accepts a freshly signed token for the subject it was minted for', () => {
|
||||
expect(verifyR2UploadToken(createR2UploadToken(SUBJECT), SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token whose payload was tampered with', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
const [encodedPayload, signature] = token.split('.');
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload!, 'base64url').toString('utf8'));
|
||||
payload.pid = 'project-victim';
|
||||
const forged = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
|
||||
expect(verifyR2UploadToken(`${forged}.${signature}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token whose signature was tampered with', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
const [encodedPayload, signature] = token.split('.');
|
||||
const flipped = (signature![0] === 'A' ? 'B' : 'A') + signature!.slice(1);
|
||||
|
||||
expect(verifyR2UploadToken(`${encodedPayload}.${flipped}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token signed under a different secret', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token that has expired', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('still accepts a token in its final second', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 59_000));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a token at the exact expiry second and rejects it one second later', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 60_000));
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(true);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token minted with a zero ttl once the clock moves on', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 0);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 1_000));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a different user', { userId: 'user-2' }],
|
||||
['a different project', { projectId: 'project-2' }],
|
||||
['a different object key', { objectKey: 'projects/project-1/videos/video-2/source.mp4' }],
|
||||
['a different upload session', { sessionId: 'session-2' }],
|
||||
['a different token id', { tokenId: 'token-2' }],
|
||||
['a different thumbnail key', { thumbnailObjectKey: 'projects/other/thumb.jpg' }],
|
||||
])('rejects a valid token presented for %s', (_label, override) => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(verifyR2UploadToken(token, { ...SUBJECT, ...override })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an object key that differs only by a traversal segment', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(
|
||||
verifyR2UploadToken(token, {
|
||||
...SUBJECT,
|
||||
objectKey: 'projects/project-1/videos/video-1/../video-2/source.mp4',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the optional session, token id and thumbnail checks when the caller omits them', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
expect(
|
||||
verifyR2UploadToken(token, {
|
||||
userId: SUBJECT.userId,
|
||||
projectId: SUBJECT.projectId,
|
||||
objectKey: SUBJECT.objectKey,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['whitespace', ' '],
|
||||
['a single segment', 'notatoken'],
|
||||
['three segments', 'a.b.c'],
|
||||
['a missing signature', 'YWJj.'],
|
||||
['a missing payload', '.c2ln'],
|
||||
['two empty segments', '.'],
|
||||
['a jwt-shaped token', 'eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1c2VyLTEifQ.sig'],
|
||||
['punctuation only', '!!!.???'],
|
||||
])('refuses %s rather than throwing', (_label, token) => {
|
||||
expect(() => verifyR2UploadToken(token, SUBJECT)).not.toThrow();
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a signature of the wrong length without letting timingSafeEqual throw', () => {
|
||||
const [encodedPayload] = createR2UploadToken(SUBJECT).split('.');
|
||||
|
||||
// crypto.timingSafeEqual throws on unequal buffer lengths, so the length
|
||||
// guard in front of it is load bearing.
|
||||
expect(() => verifyR2UploadToken(`${encodedPayload}.short`, SUBJECT)).not.toThrow();
|
||||
expect(verifyR2UploadToken(`${encodedPayload}.short`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is not JSON', () => {
|
||||
const encoded = Buffer.from('not json at all', 'utf8').toString('base64url');
|
||||
const signature = crypto.createHmac('sha256', SECRET).update(encoded).digest('base64url');
|
||||
|
||||
expect(verifyR2UploadToken(`${encoded}.${signature}`, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a correctly signed payload that is a JSON scalar rather than an object', () => {
|
||||
expect(verifyR2UploadToken(signArbitrary('user-1'), SUBJECT)).toBe(false);
|
||||
expect(verifyR2UploadToken(signArbitrary(null), SUBJECT)).toBe(false);
|
||||
expect(verifyR2UploadToken(signArbitrary(42), SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['typ'], ['uid'], ['pid'], ['key'], ['sid'], ['jti'], ['tkey'], ['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload missing %s',
|
||||
(field) => {
|
||||
const payload = wellFormedPayload();
|
||||
delete (payload as Record<string, unknown>)[field];
|
||||
|
||||
expect(verifyR2UploadToken(signArbitrary(payload), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses a correctly signed token minted for a different token type', () => {
|
||||
// Stops a bunny-upload grant, signed with the same NEXTAUTH_SECRET fallback,
|
||||
// from being replayed against the R2 path.
|
||||
expect(
|
||||
verifyR2UploadToken(signArbitrary(wellFormedPayload({ typ: 'bunny-upload' })), SUBJECT)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['exp', 'Infinity', Number.POSITIVE_INFINITY],
|
||||
['exp', 'NaN', Number.NaN],
|
||||
['iat', 'Infinity', Number.POSITIVE_INFINITY],
|
||||
])(
|
||||
'refuses a correctly signed payload whose %s arrives as null, having been minted as %s',
|
||||
(field, _label, value) => {
|
||||
// Named for what it actually exercises. JSON.stringify writes both Infinity
|
||||
// and NaN as `null`, so the payload reaches verify() with a null and is
|
||||
// rejected one line earlier, by the `typeof === 'number'` check. The
|
||||
// Number.isFinite guard is never consulted on this path; the case below is
|
||||
// the one that reaches it.
|
||||
const token = signArbitrary(wellFormedPayload({ [field]: value }));
|
||||
|
||||
expect(
|
||||
JSON.parse(Buffer.from(token.split('.')[0]!, 'base64url').toString())[field]
|
||||
).toBeNull();
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([['iat'], ['exp']])(
|
||||
'refuses a correctly signed payload whose %s is a JSON literal that overflows to Infinity',
|
||||
(field) => {
|
||||
// The one way a non-finite number survives the wire: `1e999` is legal JSON
|
||||
// and JSON.parse turns it into Infinity, which passes the typeof check and
|
||||
// leaves Number.isFinite as the only thing standing. For exp that matters,
|
||||
// because Infinity < now is false, so without the guard the token would
|
||||
// verify and never expire. Minting one still needs the server secret, so
|
||||
// this is defence in depth rather than a reachable forgery.
|
||||
const json = JSON.stringify(wellFormedPayload()).replace(
|
||||
new RegExp(`"${field}":\\d+`),
|
||||
`"${field}":1e999`
|
||||
);
|
||||
|
||||
expect(JSON.parse(json)[field]).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(verifyR2UploadToken(signRawJson(json), SUBJECT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses a correctly signed payload whose exp is a numeric string', () => {
|
||||
const token = signArbitrary(wellFormedPayload({ exp: String(NOW_SECONDS + ONE_HOUR) }));
|
||||
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false rather than throwing when the server has no secret configured', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
|
||||
// A misconfigured server is indistinguishable from a forged token here.
|
||||
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseR2UploadToken', () => {
|
||||
it('returns the payload of a valid token', () => {
|
||||
expect(parseR2UploadToken(createR2UploadToken(SUBJECT))).toMatchObject({
|
||||
typ: 'r2-upload',
|
||||
uid: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a token signed under a different secret', () => {
|
||||
const token = createR2UploadToken(SUBJECT);
|
||||
|
||||
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
||||
|
||||
expect(parseR2UploadToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an expired token', () => {
|
||||
const token = createR2UploadToken(SUBJECT, 60);
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
||||
|
||||
expect(parseR2UploadToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for garbage input rather than throwing', () => {
|
||||
expect(parseR2UploadToken('')).toBeNull();
|
||||
expect(parseR2UploadToken('a.b.c')).toBeNull();
|
||||
expect(parseR2UploadToken('%%%.%%%')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not check the payload against any subject, leaving that to the caller', () => {
|
||||
// parseR2UploadToken only proves authenticity and freshness. Routes that use
|
||||
// it directly must compare the fields themselves.
|
||||
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
||||
|
||||
expect(payload!.uid).toBe('user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,760 @@
|
||||
// lib/r2.ts is stubbed wholesale in tests/setup/api.ts so the API suite never
|
||||
// speaks S3. This file stubs the boundary instead: the real S3Client is
|
||||
// constructed and the real presigner runs, only `send()` is replaced. That way
|
||||
// the assertions are about the command objects the module builds (bucket, key,
|
||||
// part number, range, abort path), which is where the bugs would be.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateBucketCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
GetBucketCorsCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutBucketCorsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
|
||||
// lib/r2.ts snapshots every R2_* variable into a module-level const when it is
|
||||
// evaluated, so these have to be in place before the import below runs.
|
||||
// vi.hoisted() is the only hook that fires early enough. All dummy values.
|
||||
vi.hoisted(() => {
|
||||
process.env.R2_ENDPOINT = 'http://minio.test:9000';
|
||||
process.env.R2_ACCESS_KEY_ID = 'unit-test-access-key';
|
||||
process.env.R2_SECRET_ACCESS_KEY = 'unit-test-secret-key';
|
||||
process.env.R2_BUCKET_NAME = 'openframe-unit';
|
||||
delete process.env.R2_ACCOUNT_ID;
|
||||
delete process.env.R2_PRESIGN_ENDPOINT;
|
||||
delete process.env.R2_PUBLIC_BASE_URL;
|
||||
});
|
||||
|
||||
import {
|
||||
R2_BUCKET_NAME,
|
||||
abortMultipartVideoUpload,
|
||||
completeMultipartVideoUpload,
|
||||
createMultipartVideoUpload,
|
||||
createPresignedImagePutUrl,
|
||||
createPresignedUploadPartUrl,
|
||||
createPresignedVideoPutUrl,
|
||||
deleteR2Object,
|
||||
deleteVideoObject,
|
||||
ensureR2BucketExists,
|
||||
ensureR2UploadCors,
|
||||
getR2PublicObjectUrl,
|
||||
getR2UploadCorsOrigins,
|
||||
headVideoObject,
|
||||
readVideoObjectBytes,
|
||||
uploadAudio,
|
||||
} from '@/lib/r2';
|
||||
|
||||
const BUCKET = 'openframe-unit';
|
||||
const VIDEO_KEY = 'videos/11111111-2222-4333-8444-555555555555.mp4';
|
||||
|
||||
let send: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** The command object handed to the Nth send() call. */
|
||||
function commandAt(index: number): { input: Record<string, unknown> } {
|
||||
return send.mock.calls[index][0] as { input: Record<string, unknown> };
|
||||
}
|
||||
|
||||
function inputAt(index: number): Record<string, unknown> {
|
||||
return commandAt(index).input;
|
||||
}
|
||||
|
||||
/** An AWS SDK error carries its HTTP status under $metadata, not on the Error. */
|
||||
function s3Error(httpStatusCode: number | undefined): Error {
|
||||
const error = new Error('s3 rejected the request');
|
||||
if (httpStatusCode !== undefined) {
|
||||
Object.assign(error, { $metadata: { httpStatusCode } });
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
send = vi.spyOn(S3Client.prototype, 'send').mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('R2_BUCKET_NAME', () => {
|
||||
it('re-exports the configured bucket so callers do not read the env twice', () => {
|
||||
expect(R2_BUCKET_NAME).toBe(BUCKET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getR2PublicObjectUrl', () => {
|
||||
it('serves objects from the endpoint and bucket when no public base url is set', () => {
|
||||
expect(getR2PublicObjectUrl('voice/note.webm')).toBe(
|
||||
'http://minio.test:9000/openframe-unit/voice/note.webm'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips leading slashes so the key is never doubled up', () => {
|
||||
expect(getR2PublicObjectUrl('///voice/note.webm')).toBe(
|
||||
'http://minio.test:9000/openframe-unit/voice/note.webm'
|
||||
);
|
||||
});
|
||||
|
||||
// The remaining branches read env captured at module load, so they need a
|
||||
// fresh module registry rather than a stubEnv on the already-loaded copy.
|
||||
async function loadWith(env: Record<string, string | undefined>) {
|
||||
vi.resetModules();
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
vi.stubEnv(name, value);
|
||||
}
|
||||
return import('@/lib/r2');
|
||||
}
|
||||
|
||||
it('prefers R2_PUBLIC_BASE_URL over the endpoint and trims its trailing slashes', async () => {
|
||||
const r2 = await loadWith({ R2_PUBLIC_BASE_URL: 'https://cdn.example.com//' });
|
||||
|
||||
expect(r2.getR2PublicObjectUrl('images/a.png')).toBe('https://cdn.example.com/images/a.png');
|
||||
});
|
||||
|
||||
it('builds the Cloudflare virtual-host url when only an account id is configured', async () => {
|
||||
const r2 = await loadWith({
|
||||
R2_ENDPOINT: undefined,
|
||||
R2_PUBLIC_BASE_URL: undefined,
|
||||
R2_ACCOUNT_ID: 'acct-123',
|
||||
});
|
||||
|
||||
expect(r2.getR2PublicObjectUrl('images/a.png')).toBe(
|
||||
'https://openframe-unit.acct-123.r2.cloudflarestorage.com/images/a.png'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws rather than emitting a half-formed url when nothing is configured', async () => {
|
||||
const r2 = await loadWith({
|
||||
R2_ENDPOINT: undefined,
|
||||
R2_PUBLIC_BASE_URL: undefined,
|
||||
R2_ACCOUNT_ID: undefined,
|
||||
});
|
||||
|
||||
expect(() => r2.getR2PublicObjectUrl('images/a.png')).toThrow(
|
||||
'Missing R2_PUBLIC_BASE_URL or R2_ACCOUNT_ID'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureR2BucketExists', () => {
|
||||
it('stops after the head when the bucket already exists', async () => {
|
||||
await ensureR2BucketExists();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(commandAt(0)).toBeInstanceOf(HeadBucketCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET });
|
||||
});
|
||||
|
||||
it.each([404, 301, 403])('creates the bucket when the head answers %i', async (status) => {
|
||||
send.mockRejectedValueOnce(s3Error(status)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2BucketExists();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(commandAt(1)).toBeInstanceOf(CreateBucketCommand);
|
||||
expect(inputAt(1)).toEqual({ Bucket: BUCKET });
|
||||
});
|
||||
|
||||
it('rethrows an unexpected head failure instead of trying to create the bucket', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(500));
|
||||
|
||||
await expect(ensureR2BucketExists()).rejects.toThrow('s3 rejected the request');
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a failure with no http status as a missing bucket', async () => {
|
||||
// A DNS or socket failure has no $metadata, so the guard falls through.
|
||||
send.mockRejectedValueOnce(s3Error(undefined)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2BucketExists();
|
||||
|
||||
expect(commandAt(1)).toBeInstanceOf(CreateBucketCommand);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAudio', () => {
|
||||
it('writes under the voice prefix and returns the public url', async () => {
|
||||
const url = await uploadAudio(Buffer.from('audio'), 'note.webm');
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(PutObjectCommand);
|
||||
expect(inputAt(0)).toMatchObject({
|
||||
Bucket: BUCKET,
|
||||
Key: 'voice/note.webm',
|
||||
ContentType: 'audio/webm',
|
||||
});
|
||||
expect(url).toBe('http://minio.test:9000/openframe-unit/voice/note.webm');
|
||||
});
|
||||
|
||||
it('keeps only the basename so a traversal cannot escape the voice prefix', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), '../../etc/passwd');
|
||||
|
||||
expect(inputAt(0).Key).toBe('voice/passwd');
|
||||
});
|
||||
|
||||
it('strips a windows-style path separator too', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), 'C:\\Users\\x\\note.webm');
|
||||
|
||||
expect(inputAt(0).Key).toBe('voice/note.webm');
|
||||
});
|
||||
|
||||
it('strips a dot run left behind after the basename is taken', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), 'a..b.webm');
|
||||
|
||||
expect(inputAt(0).Key).toBe('voice/ab.webm');
|
||||
});
|
||||
|
||||
it('rejects a filename that sanitises down to nothing', async () => {
|
||||
await expect(uploadAudio(Buffer.from('audio'), 'dir/')).rejects.toThrow('Invalid filename');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours an explicit content type', async () => {
|
||||
await uploadAudio(Buffer.from('audio'), 'note.mp3', 'audio/mpeg');
|
||||
|
||||
expect(inputAt(0).ContentType).toBe('audio/mpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPresignedVideoPutUrl', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl('images/a.png', 'video/mp4', BigInt(1))
|
||||
).rejects.toThrow('Invalid video object key');
|
||||
});
|
||||
|
||||
it('refuses a key that only mentions the prefix further along', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl('evil/videos/a.mp4', 'video/mp4', BigInt(1))
|
||||
).rejects.toThrow('Invalid video object key');
|
||||
});
|
||||
|
||||
it.each([BigInt(0), BigInt(-1)])('refuses a content length of %s', async (length) => {
|
||||
await expect(createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', length)).rejects.toThrow(
|
||||
'Invalid video content length'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a content length past the safe integer range', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl(
|
||||
VIDEO_KEY,
|
||||
'video/mp4',
|
||||
BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)
|
||||
)
|
||||
).rejects.toThrow('Invalid video content length');
|
||||
});
|
||||
|
||||
it('accepts the largest representable content length', async () => {
|
||||
await expect(
|
||||
createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(Number.MAX_SAFE_INTEGER))
|
||||
).resolves.toContain('X-Amz-Signature=');
|
||||
});
|
||||
|
||||
it('signs a one hour PUT against the bucket and key by default', async () => {
|
||||
const url = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
||||
|
||||
// Everything here is deterministic; the signature itself deliberately is not.
|
||||
expect(url.origin).toBe('http://minio.test:9000');
|
||||
expect(url.pathname).toBe(`/${BUCKET}/${VIDEO_KEY}`);
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
|
||||
expect(url.searchParams.get('x-id')).toBe('PutObject');
|
||||
expect(url.searchParams.get('X-Amz-Signature')).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours a caller-supplied expiry window', async () => {
|
||||
const url = new URL(
|
||||
await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024), 900)
|
||||
);
|
||||
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('900');
|
||||
});
|
||||
|
||||
it('binds the content length into the signature so the size cannot be swapped', async () => {
|
||||
const url = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
||||
|
||||
expect(url.searchParams.get('X-Amz-SignedHeaders')?.split(';')).toContain('content-length');
|
||||
});
|
||||
|
||||
it('produces a different signature for a different key', async () => {
|
||||
const a = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
||||
const b = new URL(
|
||||
await createPresignedVideoPutUrl('videos/other.mp4', 'video/mp4', BigInt(1024))
|
||||
);
|
||||
|
||||
expect(a.searchParams.get('X-Amz-Signature')).not.toBe(b.searchParams.get('X-Amz-Signature'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPresignedImagePutUrl', () => {
|
||||
it('refuses a key outside the images prefix', async () => {
|
||||
await expect(createPresignedImagePutUrl(VIDEO_KEY, 'image/png')).rejects.toThrow(
|
||||
'Invalid image object key'
|
||||
);
|
||||
});
|
||||
|
||||
it('signs a PUT against the bucket and key', async () => {
|
||||
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png', 120));
|
||||
|
||||
expect(url.origin).toBe('http://minio.test:9000');
|
||||
expect(url.pathname).toBe(`/${BUCKET}/images/avatar.png`);
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('120');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to a one hour window', async () => {
|
||||
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
|
||||
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
|
||||
});
|
||||
|
||||
// Documents current behaviour rather than endorsing it: ContentType is passed
|
||||
// to the command but the presigner does not sign it, so the grant does not
|
||||
// pin the uploaded media type. See the note in the review notes.
|
||||
it('does not bind the content type into the signature', async () => {
|
||||
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
|
||||
|
||||
expect(url.searchParams.get('X-Amz-SignedHeaders')).toBe('host');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMultipartVideoUpload', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(createMultipartVideoUpload('images/a.png', 'video/mp4')).rejects.toThrow(
|
||||
'Invalid video object key'
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the upload id the service assigned', async () => {
|
||||
send.mockResolvedValueOnce({ UploadId: 'upload-abc' } as never);
|
||||
|
||||
await expect(createMultipartVideoUpload(VIDEO_KEY, 'video/mp4')).resolves.toBe('upload-abc');
|
||||
expect(commandAt(0)).toBeInstanceOf(CreateMultipartUploadCommand);
|
||||
expect(inputAt(0)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
Key: VIDEO_KEY,
|
||||
ContentType: 'video/mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the service answers without an upload id', async () => {
|
||||
send.mockResolvedValueOnce({} as never);
|
||||
|
||||
await expect(createMultipartVideoUpload(VIDEO_KEY, 'video/mp4')).rejects.toThrow(
|
||||
'Failed to create multipart upload'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPresignedUploadPartUrl', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(createPresignedUploadPartUrl('images/a.png', 'upload-1', 1)).rejects.toThrow(
|
||||
'Invalid video object key'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, -1, 10001, 1.5, Number.NaN])('refuses part number %s', async (partNumber) => {
|
||||
await expect(createPresignedUploadPartUrl(VIDEO_KEY, 'upload-1', partNumber)).rejects.toThrow(
|
||||
'Invalid part number'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 10000])('accepts the boundary part number %i', async (partNumber) => {
|
||||
const url = new URL(await createPresignedUploadPartUrl(VIDEO_KEY, 'upload-1', partNumber));
|
||||
|
||||
expect(url.searchParams.get('partNumber')).toBe(String(partNumber));
|
||||
});
|
||||
|
||||
it('signs the part number and upload id into the query string', async () => {
|
||||
const url = new URL(await createPresignedUploadPartUrl(VIDEO_KEY, 'upload-abc', 7, 600));
|
||||
|
||||
expect(url.origin).toBe('http://minio.test:9000');
|
||||
expect(url.pathname).toBe(`/${BUCKET}/${VIDEO_KEY}`);
|
||||
expect(url.searchParams.get('partNumber')).toBe('7');
|
||||
expect(url.searchParams.get('uploadId')).toBe('upload-abc');
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('600');
|
||||
expect(url.searchParams.get('x-id')).toBe('UploadPart');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeMultipartVideoUpload', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(
|
||||
completeMultipartVideoUpload('images/a.png', 'upload-1', [{ partNumber: 1, etag: 'e1' }])
|
||||
).rejects.toThrow('Invalid video object key');
|
||||
});
|
||||
|
||||
it('refuses an empty part list rather than completing an empty object', async () => {
|
||||
await expect(completeMultipartVideoUpload(VIDEO_KEY, 'upload-1', [])).rejects.toThrow(
|
||||
'No parts provided for multipart completion'
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sorts the parts by number because S3 rejects an out-of-order manifest', async () => {
|
||||
await completeMultipartVideoUpload(VIDEO_KEY, 'upload-abc', [
|
||||
{ partNumber: 3, etag: 'etag-3' },
|
||||
{ partNumber: 1, etag: 'etag-1' },
|
||||
{ partNumber: 2, etag: 'etag-2' },
|
||||
]);
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(CompleteMultipartUploadCommand);
|
||||
expect(inputAt(0)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
Key: VIDEO_KEY,
|
||||
UploadId: 'upload-abc',
|
||||
MultipartUpload: {
|
||||
Parts: [
|
||||
{ PartNumber: 1, ETag: 'etag-1' },
|
||||
{ PartNumber: 2, ETag: 'etag-2' },
|
||||
{ PartNumber: 3, ETag: 'etag-3' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reorder the array the caller passed in', async () => {
|
||||
const parts = [
|
||||
{ partNumber: 2, etag: 'etag-2' },
|
||||
{ partNumber: 1, etag: 'etag-1' },
|
||||
];
|
||||
|
||||
await completeMultipartVideoUpload(VIDEO_KEY, 'upload-abc', parts);
|
||||
|
||||
expect(parts.map((part) => part.partNumber)).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abortMultipartVideoUpload', () => {
|
||||
it('refuses a key outside the videos prefix', async () => {
|
||||
await expect(abortMultipartVideoUpload('images/a.png', 'upload-1')).rejects.toThrow(
|
||||
'Invalid video object key'
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the named upload on the named key', async () => {
|
||||
await abortMultipartVideoUpload(VIDEO_KEY, 'upload-abc');
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(AbortMultipartUploadCommand);
|
||||
expect(inputAt(0)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
Key: VIDEO_KEY,
|
||||
UploadId: 'upload-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates a failed abort so the caller can retry or alarm', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(500));
|
||||
|
||||
await expect(abortMultipartVideoUpload(VIDEO_KEY, 'upload-abc')).rejects.toThrow(
|
||||
's3 rejected the request'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headVideoObject', () => {
|
||||
it('returns null for a key outside the videos prefix without touching the network', async () => {
|
||||
await expect(headVideoObject('images/a.png')).resolves.toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the length as a bigint and passes the content type through', async () => {
|
||||
send.mockResolvedValueOnce({ ContentLength: 4096, ContentType: 'video/mp4' } as never);
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
||||
contentLength: BigInt(4096),
|
||||
contentType: 'video/mp4',
|
||||
});
|
||||
expect(commandAt(0)).toBeInstanceOf(HeadObjectCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY });
|
||||
});
|
||||
|
||||
it('falls back to zero when the service omits the length', async () => {
|
||||
send.mockResolvedValueOnce({ ContentType: 'video/mp4' } as never);
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
||||
contentLength: BigInt(0),
|
||||
contentType: 'video/mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to zero rather than throwing on a negative length', async () => {
|
||||
send.mockResolvedValueOnce({ ContentLength: -1 } as never);
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
||||
contentLength: BigInt(0),
|
||||
contentType: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a missing object', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(404));
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rethrows any other failure so a broken bucket is not read as an empty one', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(403));
|
||||
|
||||
await expect(headVideoObject(VIDEO_KEY)).rejects.toThrow('s3 rejected the request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readVideoObjectBytes', () => {
|
||||
function bodyOf(bytes: Uint8Array) {
|
||||
return { Body: { transformToByteArray: async () => bytes } };
|
||||
}
|
||||
|
||||
it('returns null for a key outside the videos prefix', async () => {
|
||||
await expect(readVideoObjectBytes('images/a.png', 16)).resolves.toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, -5])('returns null for a byte length of %i', async (byteLength) => {
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, byteLength)).resolves.toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests an inclusive range that is one byte shorter than the length asked for', async () => {
|
||||
send.mockResolvedValueOnce(bodyOf(new Uint8Array([1, 2, 3, 4])) as never);
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
expect(commandAt(0)).toBeInstanceOf(GetObjectCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY, Range: 'bytes=0-3' });
|
||||
});
|
||||
|
||||
it('asks for a single byte when one byte is requested', async () => {
|
||||
send.mockResolvedValueOnce(bodyOf(new Uint8Array([1])) as never);
|
||||
|
||||
await readVideoObjectBytes(VIDEO_KEY, 1);
|
||||
|
||||
expect(inputAt(0).Range).toBe('bytes=0-0');
|
||||
});
|
||||
|
||||
it('returns null when the response carries no body', async () => {
|
||||
send.mockResolvedValueOnce({} as never);
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the body cannot be collected into bytes', async () => {
|
||||
send.mockResolvedValueOnce({ Body: {} } as never);
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([404, 416])('returns null when the range read answers %i', async (status) => {
|
||||
send.mockRejectedValueOnce(s3Error(status));
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rethrows any other read failure', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(500));
|
||||
|
||||
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).rejects.toThrow('s3 rejected the request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteVideoObject and deleteR2Object', () => {
|
||||
it('deletes a video key', async () => {
|
||||
await deleteVideoObject(VIDEO_KEY);
|
||||
|
||||
expect(commandAt(0)).toBeInstanceOf(DeleteObjectCommand);
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY });
|
||||
});
|
||||
|
||||
it('refuses an image key through the video-specific entry point', async () => {
|
||||
await expect(deleteVideoObject('images/a.png')).rejects.toThrow('Invalid video object key');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an image key through the general entry point', async () => {
|
||||
await deleteR2Object('images/a.png');
|
||||
|
||||
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: 'images/a.png' });
|
||||
});
|
||||
|
||||
// The allowlist is the whole safety story for delete: anything that is not a
|
||||
// video or an image key must never reach DeleteObject.
|
||||
it.each([
|
||||
'voice/note.webm',
|
||||
'',
|
||||
'/videos/a.mp4',
|
||||
'other/videos/a.mp4',
|
||||
'../videos/a.mp4',
|
||||
'videos',
|
||||
])('refuses to delete %s', async (key) => {
|
||||
await expect(deleteR2Object(key)).rejects.toThrow('Invalid object key');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getR2UploadCorsOrigins', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
});
|
||||
|
||||
it('reduces each configured url to its origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com/some/path');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['https://app.example.com']);
|
||||
});
|
||||
|
||||
it('deduplicates urls that share an origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com/');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['https://app.example.com']);
|
||||
});
|
||||
|
||||
it('keeps the port, which is what makes a local origin distinct', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'http://localhost:3000');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['http://localhost:3000']);
|
||||
});
|
||||
|
||||
it('appends caller-supplied origins after the configured ones', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
|
||||
expect(getR2UploadCorsOrigins(['https://extra.example.com'])).toEqual([
|
||||
'https://app.example.com',
|
||||
'https://extra.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips blank and unparseable entries instead of throwing', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', ' ');
|
||||
|
||||
expect(getR2UploadCorsOrigins(['not a url', ''])).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds the loopback development origins only in development', () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
|
||||
expect(getR2UploadCorsOrigins()).toEqual(['http://localhost:3000', 'http://127.0.0.1:3000']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureR2UploadCors', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
});
|
||||
|
||||
const managedRule = {
|
||||
AllowedOrigins: ['https://app.example.com'],
|
||||
AllowedMethods: ['GET', 'PUT', 'HEAD'],
|
||||
AllowedHeaders: ['*'],
|
||||
ExposeHeaders: ['ETag'],
|
||||
MaxAgeSeconds: 3600,
|
||||
};
|
||||
|
||||
it('refuses to run with no origins rather than opening the bucket to everyone', async () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
|
||||
await expect(ensureR2UploadCors()).rejects.toThrow('No origins configured for R2 upload CORS');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an existing rule alone when it already covers the origin', async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
CORSRules: [{ AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['GET', 'PUT'] }],
|
||||
} as never);
|
||||
|
||||
await expect(ensureR2UploadCors()).resolves.toEqual(['https://app.example.com']);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(commandAt(0)).toBeInstanceOf(GetBucketCorsCommand);
|
||||
});
|
||||
|
||||
it('accepts HEAD in place of GET on the existing rule', async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
CORSRules: [{ AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['head', 'put'] }],
|
||||
} as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('appends its own rule when the existing rules omit PUT', async () => {
|
||||
const existing = { AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['GET'] };
|
||||
send.mockResolvedValueOnce({ CORSRules: [existing] } as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(commandAt(1)).toBeInstanceOf(PutBucketCorsCommand);
|
||||
expect(inputAt(1)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
CORSConfiguration: { CORSRules: [existing, managedRule] },
|
||||
});
|
||||
});
|
||||
|
||||
it('appends its own rule when the existing rules cover a different origin', async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
CORSRules: [
|
||||
{ AllowedOrigins: ['https://other.example.com'], AllowedMethods: ['GET', 'PUT'] },
|
||||
],
|
||||
} as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(commandAt(1)).toBeInstanceOf(PutBucketCorsCommand);
|
||||
});
|
||||
|
||||
it('writes a fresh configuration when the bucket has no CORS config to read', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(404)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await expect(ensureR2UploadCors()).resolves.toEqual(['https://app.example.com']);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(inputAt(1)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
CORSConfiguration: { CORSRules: [managedRule] },
|
||||
});
|
||||
});
|
||||
|
||||
// The try block wraps the write as well as the read, so a write that fails
|
||||
// lands in the same catch as "no config to read" and the retry re-sends only
|
||||
// the managed rule. Asserted as-is; see the review notes.
|
||||
it('drops the pre-existing rules when the first write fails and the retry succeeds', async () => {
|
||||
const existing = { AllowedOrigins: ['https://other.example.com'], AllowedMethods: ['GET'] };
|
||||
send
|
||||
.mockResolvedValueOnce({ CORSRules: [existing] } as never)
|
||||
.mockRejectedValueOnce(s3Error(500))
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2UploadCors();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
expect(inputAt(2)).toEqual({
|
||||
Bucket: BUCKET,
|
||||
CORSConfiguration: { CORSRules: [managedRule] },
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the extra origins it was handed in the rule it writes', async () => {
|
||||
send.mockRejectedValueOnce(s3Error(404)).mockResolvedValueOnce({} as never);
|
||||
|
||||
await ensureR2UploadCors(['https://preview.example.com']);
|
||||
|
||||
expect(
|
||||
(inputAt(1).CORSConfiguration as { CORSRules: Array<{ AllowedOrigins: string[] }> })
|
||||
.CORSRules[0].AllowedOrigins
|
||||
).toEqual(['https://app.example.com', 'https://preview.example.com']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import {
|
||||
getAllowedRequestOrigins,
|
||||
getPublicOrigin,
|
||||
isTrustedSameOriginRequest,
|
||||
} from '@/lib/request-origin';
|
||||
|
||||
const APP_URL = 'https://app.openframe.test';
|
||||
const REQUEST_URL = `${APP_URL}/api/billing/checkout`;
|
||||
|
||||
function request(headers: Record<string, string> = {}, url = REQUEST_URL): NextRequest {
|
||||
return new NextRequest(url, { method: 'POST', headers: new Headers(headers) });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The `unit` project loads no env file, so whatever the shell happens to export
|
||||
// would otherwise decide the allowed-origin set. Pin both variables.
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('isTrustedSameOriginRequest', () => {
|
||||
it('trusts a request whose Origin matches the request origin', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: APP_URL }))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a request from a different origin', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://evil.example.com' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a request with no Origin header at all', () => {
|
||||
expect(isTrustedSameOriginRequest(request())).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses the literal "null" Origin a sandboxed iframe sends', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'null' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an empty Origin header', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: '' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an unparseable Origin instead of throwing', () => {
|
||||
expect(() => isTrustedSameOriginRequest(request({ origin: 'not a url' }))).not.toThrow();
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'not a url' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a different scheme on the same host', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'http://app.openframe.test' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a different port on the same host', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://app.openframe.test:8443' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an attacker subdomain of the trusted host', () => {
|
||||
expect(
|
||||
isTrustedSameOriginRequest(request({ origin: 'https://app.openframe.test.evil.com' }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a host that merely starts with the trusted host', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://app.openframe.testing' }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('trusts an operator-configured origin that differs from the request origin', () => {
|
||||
// The Docker case: the container sees localhost:3000, the browser sees the
|
||||
// public hostname, and the Origin header carries the latter.
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://frames.example.com');
|
||||
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request(
|
||||
{ origin: 'https://frames.example.com' },
|
||||
'http://localhost:3000/api/billing/portal'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts an origin configured through NEXTAUTH_URL', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://frames.example.com/api/auth');
|
||||
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request(
|
||||
{ origin: 'https://frames.example.com' },
|
||||
'http://localhost:3000/api/billing/portal'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('still refuses a third origin when both variables are configured', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'https://c.example.com' }))).toBe(false);
|
||||
});
|
||||
|
||||
// The header comment in lib/request-origin.ts calls this out explicitly: the
|
||||
// x-forwarded-* headers are client controlled, so trusting them would let any
|
||||
// caller name its own origin as the allowed one.
|
||||
it('does not let a forged x-forwarded-host widen the allowed set', () => {
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request({
|
||||
origin: 'https://evil.example.com',
|
||||
'x-forwarded-host': 'evil.example.com',
|
||||
'x-forwarded-proto': 'https',
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let a forged Host header widen the allowed set', () => {
|
||||
expect(
|
||||
isTrustedSameOriginRequest(
|
||||
request({ origin: 'https://evil.example.com', host: 'evil.example.com' })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('compares only the origin, ignoring a path or query the caller appended', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: `${APP_URL}/some/path?a=1` }))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores case in the scheme and host, as URL parsing normalizes both', () => {
|
||||
expect(isTrustedSameOriginRequest(request({ origin: 'HTTPS://APP.OPENFRAME.TEST' }))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllowedRequestOrigins', () => {
|
||||
it('always contains the server-computed request origin', () => {
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
|
||||
it('adds both configured origins alongside the request origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(
|
||||
new Set([APP_URL, 'https://a.example.com', 'https://b.example.com'])
|
||||
);
|
||||
});
|
||||
|
||||
it('reduces a configured url with a path down to its origin', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com/api/auth/callback');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toContain('https://a.example.com');
|
||||
});
|
||||
|
||||
it('assumes https for a configured value with no scheme', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'frames.example.com');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toContain('https://frames.example.com');
|
||||
});
|
||||
|
||||
it('keeps an explicitly configured http origin as http', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toContain('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('skips a blank or whitespace-only configured value', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', ' ');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', '');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
|
||||
it('skips a configured value that cannot be parsed as a url', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://');
|
||||
|
||||
expect(getAllowedRequestOrigins(request())).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
|
||||
it('collapses duplicate configured origins into one entry', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://frames.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://frames.example.com/dashboard');
|
||||
|
||||
expect(getAllowedRequestOrigins(request()).size).toBe(2);
|
||||
});
|
||||
|
||||
it('never contains an x-forwarded-derived origin', () => {
|
||||
const origins = getAllowedRequestOrigins(
|
||||
request({ 'x-forwarded-host': 'evil.example.com', 'x-forwarded-proto': 'https' })
|
||||
);
|
||||
|
||||
expect(origins).toEqual(new Set([APP_URL]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicOrigin', () => {
|
||||
it('prefers NEXTAUTH_URL over both the request origin and NEXT_PUBLIC_APP_URL', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://a.example.com');
|
||||
});
|
||||
|
||||
it('falls back to NEXT_PUBLIC_APP_URL when NEXTAUTH_URL is unset', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://b.example.com');
|
||||
});
|
||||
|
||||
it('falls back to the request origin when neither variable is configured', () => {
|
||||
// The local development case, where no reverse proxy sits in front.
|
||||
expect(getPublicOrigin(request())).toBe(APP_URL);
|
||||
});
|
||||
|
||||
it('strips the path from a configured url', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://a.example.com/api/auth');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://a.example.com');
|
||||
});
|
||||
|
||||
it('assumes https for a configured host with no scheme', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'frames.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://frames.example.com');
|
||||
});
|
||||
|
||||
it('skips a whitespace-only NEXTAUTH_URL and uses the next candidate', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', ' ');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://b.example.com');
|
||||
});
|
||||
|
||||
it('skips an unparseable NEXTAUTH_URL and uses the next candidate', () => {
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://b.example.com');
|
||||
|
||||
expect(getPublicOrigin(request())).toBe('https://b.example.com');
|
||||
});
|
||||
|
||||
it('does not build the redirect origin from a forged x-forwarded-host', () => {
|
||||
// This is the value the browser is sent to, so a spoofed host here is an
|
||||
// open redirect.
|
||||
expect(
|
||||
getPublicOrigin(
|
||||
request({ 'x-forwarded-host': 'evil.example.com', 'x-forwarded-proto': 'https' })
|
||||
)
|
||||
).toBe(APP_URL);
|
||||
});
|
||||
|
||||
it('preserves the port of the request origin when falling back', () => {
|
||||
expect(getPublicOrigin(request({}, 'http://localhost:3000/api/auth/verify-email'))).toBe(
|
||||
'http://localhost:3000'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,716 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||
import {
|
||||
hasAppNavigationAccess,
|
||||
hasCollaboratorBillingBackedAccess,
|
||||
requireAuthOrRedirect,
|
||||
requireBillingAccessOrRedirect,
|
||||
requireProjectAccessOrRedirect,
|
||||
requireVideoProjectAccessOrRedirect,
|
||||
requireWorkspaceAccessOrRedirect,
|
||||
} from '@/lib/route-access';
|
||||
|
||||
// The real redirect() and notFound() abort rendering by throwing. A mock that
|
||||
// returns normally would let execution fall through into code that can never run
|
||||
// in production, and every assertion after that point would describe a fiction.
|
||||
// In particular lib/route-access.ts has branches that call redirectForMissingAuth()
|
||||
// and then redirectForForbidden() on the following line; only a throwing mock
|
||||
// shows which of the two a real request would land on.
|
||||
const nav = vi.hoisted(() => {
|
||||
class RedirectError extends Error {
|
||||
constructor(readonly path: string) {
|
||||
super(`NEXT_REDIRECT ${path}`);
|
||||
}
|
||||
}
|
||||
class NotFoundError extends Error {
|
||||
constructor() {
|
||||
super('NEXT_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
return {
|
||||
RedirectError,
|
||||
NotFoundError,
|
||||
redirect: vi.fn((path: string): never => {
|
||||
throw new RedirectError(path);
|
||||
}),
|
||||
notFound: vi.fn((): never => {
|
||||
throw new NotFoundError();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('next/navigation', () => ({ redirect: nav.redirect, notFound: nav.notFound }));
|
||||
|
||||
// The permission formulas themselves live in lib/auth.ts and are covered by
|
||||
// tests/unit/lib/project-access.test.ts against the real matrix. Here they are
|
||||
// stubbed so each test can pin one access verdict and assert only on what
|
||||
// route-access.ts does with it.
|
||||
const authModule = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
checkProjectAccess: vi.fn(),
|
||||
checkWorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth', () => authModule);
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
workspace: { findUnique: vi.fn(), count: vi.fn() },
|
||||
project: { findUnique: vi.fn(), count: vi.fn() },
|
||||
video: { findFirst: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() }));
|
||||
|
||||
// The three redirect targets are written out by hand rather than imported, so
|
||||
// changing a target in lib/route-access.ts fails here instead of silently
|
||||
// agreeing with itself. /login and /dashboard match what the pages that do their
|
||||
// own session check use (app/(dashboard)/dashboard/page.tsx redirects anonymous
|
||||
// callers to /login); /settings is the page that renders the billing-only view
|
||||
// when hasBillingAccess is false.
|
||||
const LOGIN = '/login';
|
||||
const FORBIDDEN = '/dashboard';
|
||||
const BILLING = '/settings';
|
||||
|
||||
const NOW = new Date('2026-01-15T00:00:00.000Z');
|
||||
|
||||
const USER_ID = 'user-signed-in';
|
||||
const OTHER_USER_ID = 'user-from-session';
|
||||
const PROJECT_ID = 'project-1';
|
||||
const WORKSPACE_ID = 'workspace-1';
|
||||
const VIDEO_ID = 'video-1';
|
||||
|
||||
const ACTIVE_BILLING = {
|
||||
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
|
||||
trialEndsAt: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
};
|
||||
|
||||
const LAPSED_BILLING = {
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
trialEndsAt: new Date('2025-12-01T00:00:00.000Z'),
|
||||
stripeCurrentPeriodEnd: new Date('2025-12-08T00:00:00.000Z'),
|
||||
billingAccessEndedAt: new Date('2025-12-08T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const PROJECT_ROW = {
|
||||
id: PROJECT_ID,
|
||||
ownerId: USER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
visibility: 'PRIVATE',
|
||||
};
|
||||
|
||||
const PUBLIC_PROJECT_ROW = { ...PROJECT_ROW, visibility: 'PUBLIC' };
|
||||
|
||||
const WORKSPACE_ROW = { id: WORKSPACE_ID, ownerId: USER_ID };
|
||||
|
||||
const VIDEO_ROW = { id: VIDEO_ID, project: PROJECT_ROW };
|
||||
|
||||
type ProjectAccessResult = {
|
||||
isOwner: boolean;
|
||||
isProjectMember: boolean;
|
||||
isProjectAdmin: boolean;
|
||||
isWorkspaceMember: boolean;
|
||||
isWorkspaceAdmin: boolean;
|
||||
hasAccess: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
ownerBillingActive: boolean;
|
||||
};
|
||||
|
||||
function projectAccess(overrides: Partial<ProjectAccessResult> = {}): ProjectAccessResult {
|
||||
return {
|
||||
isOwner: false,
|
||||
isProjectMember: false,
|
||||
isProjectAdmin: false,
|
||||
isWorkspaceMember: false,
|
||||
isWorkspaceAdmin: false,
|
||||
hasAccess: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
ownerBillingActive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceAccessResult = {
|
||||
isOwner: boolean;
|
||||
isMember: boolean;
|
||||
isAdmin: boolean;
|
||||
hasAccess: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
ownerBillingActive: boolean;
|
||||
};
|
||||
|
||||
function workspaceAccess(overrides: Partial<WorkspaceAccessResult> = {}): WorkspaceAccessResult {
|
||||
return {
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
isAdmin: false,
|
||||
hasAccess: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
ownerBillingActive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the call aborted through redirect() with exactly one target. The
|
||||
* "exactly one" half matters: several branches queue a second redirect on the
|
||||
* line below, and only the first one can ever take effect at runtime.
|
||||
*/
|
||||
async function expectRedirect(call: Promise<unknown>, path: string) {
|
||||
await expect(call).rejects.toBeInstanceOf(nav.RedirectError);
|
||||
expect(nav.redirect).toHaveBeenCalledTimes(1);
|
||||
expect(nav.redirect).toHaveBeenCalledWith(path);
|
||||
expect(nav.notFound).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectNotFound(call: Promise<unknown>) {
|
||||
await expect(call).rejects.toBeInstanceOf(nav.NotFoundError);
|
||||
expect(nav.notFound).toHaveBeenCalledTimes(1);
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// hasBillingAccess() short-circuits to true when Stripe is off, which would
|
||||
// make every lapsed-billing fixture read as paid.
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('requireAuthOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireAuthOrRedirect(), LOGIN);
|
||||
});
|
||||
|
||||
it('sends a session with no user id to the login page', async () => {
|
||||
// next-auth can hand back a session object whose user was never resolved.
|
||||
authModule.auth.mockResolvedValue({ user: { email: '[email protected]' } });
|
||||
|
||||
await expectRedirect(requireAuthOrRedirect(), LOGIN);
|
||||
});
|
||||
|
||||
it('returns the session untouched for a signed-in caller', async () => {
|
||||
const session = { user: { id: USER_ID, email: '[email protected]' } };
|
||||
authModule.auth.mockResolvedValue(session);
|
||||
|
||||
await expect(requireAuthOrRedirect()).resolves.toEqual(session);
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireBillingAccessOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page without reading the user row', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireBillingAccessOrRedirect(), LOGIN);
|
||||
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a caller whose user row is gone to the billing settings page', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireBillingAccessOrRedirect({ userId: USER_ID }), BILLING);
|
||||
});
|
||||
|
||||
it('sends a caller whose billing has lapsed to the billing settings page', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
|
||||
|
||||
await expectRedirect(requireBillingAccessOrRedirect({ userId: USER_ID }), BILLING);
|
||||
});
|
||||
|
||||
it('returns the billing columns for a caller who is still paying', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
|
||||
|
||||
await expect(requireBillingAccessOrRedirect({ userId: USER_ID })).resolves.toEqual(
|
||||
ACTIVE_BILLING
|
||||
);
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps access for a caller inside an unexpired trial', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue({
|
||||
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||
trialEndsAt: new Date('2026-01-16T00:00:00.000Z'),
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
});
|
||||
|
||||
await expect(requireBillingAccessOrRedirect({ userId: USER_ID })).resolves.toBeTruthy();
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trusts the caller-supplied user id over the session', async () => {
|
||||
// Pages that already resolved a session pass the id down to save a round
|
||||
// trip; the passed id has to win, or one user is billed against another.
|
||||
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
|
||||
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
|
||||
|
||||
await requireBillingAccessOrRedirect({ userId: USER_ID });
|
||||
|
||||
expect(dbMock.user.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: USER_ID } })
|
||||
);
|
||||
expect(authModule.auth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCollaboratorBillingBackedAccess', () => {
|
||||
beforeEach(() => {
|
||||
dbMock.workspace.count.mockResolvedValue(0);
|
||||
dbMock.project.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('is true when the caller belongs to a workspace whose owner is paying', async () => {
|
||||
dbMock.workspace.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is true when the caller belongs to a project whose workspace owner is paying', async () => {
|
||||
dbMock.project.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the caller collaborates nowhere', async () => {
|
||||
await expect(hasCollaboratorBillingBackedAccess(USER_ID)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('counts only workspaces and projects whose owner is inside the billing window', async () => {
|
||||
await hasCollaboratorBillingBackedAccess(USER_ID);
|
||||
|
||||
// buildBillingAccessWhereInput comes from lib/billing, a separately tested
|
||||
// module, so this pins the filter without reading it out of route-access.
|
||||
const billingFilter = buildBillingAccessWhereInput(NOW);
|
||||
expect(dbMock.workspace.count.mock.calls[0][0].where.owner).toEqual(billingFilter);
|
||||
expect(dbMock.project.count.mock.calls[0][0].where.workspace.owner).toEqual(billingFilter);
|
||||
});
|
||||
|
||||
it('counts a workspace the caller owns and one they were only invited to', async () => {
|
||||
// The membership arm is the whole point of the workspace half: a collaborator
|
||||
// who owns no workspace of their own would lose dashboard navigation without
|
||||
// it, and the project count only papers over that while they happen to sit on
|
||||
// at least one project row.
|
||||
await hasCollaboratorBillingBackedAccess(USER_ID);
|
||||
|
||||
expect(dbMock.workspace.count.mock.calls[0][0].where.OR).toEqual([
|
||||
{ ownerId: USER_ID },
|
||||
{ members: { some: { userId: USER_ID } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts a project reached only through workspace membership', async () => {
|
||||
// A workspace COMMENTATOR is on no project row, so dropping this arm would
|
||||
// strip navigation from every workspace-level collaborator.
|
||||
await hasCollaboratorBillingBackedAccess(USER_ID);
|
||||
|
||||
expect(dbMock.project.count.mock.calls[0][0].where.OR).toEqual([
|
||||
{ ownerId: USER_ID },
|
||||
{ members: { some: { userId: USER_ID } } },
|
||||
{ workspace: { members: { some: { userId: USER_ID } } } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAppNavigationAccess', () => {
|
||||
beforeEach(() => {
|
||||
dbMock.workspace.count.mockResolvedValue(0);
|
||||
dbMock.project.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('is true for a paying user without counting collaborations', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(ACTIVE_BILLING);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
|
||||
expect(dbMock.workspace.count).not.toHaveBeenCalled();
|
||||
expect(dbMock.project.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is true for a lapsed user who still collaborates on a paid workspace', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
|
||||
dbMock.workspace.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is true when the user row is missing but a collaboration exists', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(null);
|
||||
dbMock.project.count.mockResolvedValue(1);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a lapsed user with nothing left to collaborate on', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue(LAPSED_BILLING);
|
||||
|
||||
await expect(hasAppNavigationAccess(USER_ID)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireWorkspaceAccessOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page before the workspace is read', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID }), LOGIN);
|
||||
expect(dbMock.workspace.findUnique).not.toHaveBeenCalled();
|
||||
expect(authModule.checkWorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a 404 for a signed-in caller when the workspace does not exist', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectNotFound(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: USER_ID })
|
||||
);
|
||||
expect(authModule.checkWorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a signed-in stranger to the dashboard', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(workspaceAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the owner to the billing settings page when their billing has lapsed', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(
|
||||
workspaceAccess({ isOwner: true, hasAccess: false, ownerBillingActive: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: USER_ID }),
|
||||
BILLING
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a member who cannot edit to the dashboard when the page needs manage rights', async () => {
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(
|
||||
workspaceAccess({ isMember: true, hasAccess: true, canEdit: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireWorkspaceAccessOrRedirect({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
intent: 'manage',
|
||||
}),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a member through on the default view intent even though they cannot edit', async () => {
|
||||
const access = workspaceAccess({ isMember: true, hasAccess: true, canEdit: false });
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID })
|
||||
).resolves.toEqual({ workspace: WORKSPACE_ROW, access });
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
expect(nav.notFound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an admin through on the manage intent', async () => {
|
||||
const access = workspaceAccess({
|
||||
isMember: true,
|
||||
isAdmin: true,
|
||||
hasAccess: true,
|
||||
canEdit: true,
|
||||
});
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireWorkspaceAccessOrRedirect({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
intent: 'manage',
|
||||
})
|
||||
).resolves.toEqual({ workspace: WORKSPACE_ROW, access });
|
||||
});
|
||||
|
||||
it('falls back to the session user when no id is passed', async () => {
|
||||
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
|
||||
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
|
||||
authModule.checkWorkspaceAccess.mockResolvedValue(
|
||||
workspaceAccess({ isOwner: true, hasAccess: true, canEdit: true })
|
||||
);
|
||||
|
||||
await requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID });
|
||||
|
||||
expect(authModule.checkWorkspaceAccess).toHaveBeenCalledWith(WORKSPACE_ROW, OTHER_USER_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireProjectAccessOrRedirect', () => {
|
||||
it('sends an anonymous caller to the login page before the project is read', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireProjectAccessOrRedirect({ projectId: PROJECT_ID }), LOGIN);
|
||||
expect(dbMock.project.findUnique).not.toHaveBeenCalled();
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page on a public route asking for manage rights', async () => {
|
||||
// The guest policy runs before any permission check: a guest can only ever
|
||||
// read, so a manage page is a login redirect regardless of the project.
|
||||
dbMock.project.findUnique.mockResolvedValue(PUBLIC_PROJECT_ROW);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({
|
||||
projectId: PROJECT_ID,
|
||||
intent: 'manage',
|
||||
allowPublicView: true,
|
||||
}),
|
||||
LOGIN
|
||||
);
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page rather than a 404 for a missing project', async () => {
|
||||
// A guest must not be able to tell a project that does not exist apart from
|
||||
// one they cannot see; both answers have to look the same.
|
||||
dbMock.project.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page, not the dashboard, when a public route holds a private project', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a 404 for a signed-in caller when the project does not exist', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expectNotFound(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: USER_ID })
|
||||
);
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a signed-in stranger to the dashboard', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: OTHER_USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the owner to the dashboard when the workspace owner billing has lapsed', async () => {
|
||||
// Unlike the workspace helper this path has no /settings branch: a lapsed
|
||||
// owner lands on /dashboard, which runs its own billing gate and forwards
|
||||
// them to /settings from there.
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isOwner: true, hasAccess: false, ownerBillingActive: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a read-only member to the dashboard when the page needs manage rights', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isProjectMember: true, hasAccess: true, canEdit: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireProjectAccessOrRedirect({
|
||||
projectId: PROJECT_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
intent: 'manage',
|
||||
}),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the project row and the access verdict for a permitted viewer', async () => {
|
||||
const access = projectAccess({ isProjectMember: true, hasAccess: true });
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, userId: OTHER_USER_ID })
|
||||
).resolves.toEqual({ project: PROJECT_ROW, access });
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
expect(nav.notFound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an anonymous viewer read a public project when the route opts in', async () => {
|
||||
const access = projectAccess({ hasAccess: true });
|
||||
dbMock.project.findUnique.mockResolvedValue(PUBLIC_PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true })
|
||||
).resolves.toEqual({ project: PUBLIC_PROJECT_ROW, access });
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PUBLIC_PROJECT_ROW, undefined, {
|
||||
intent: 'view',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the manage intent down to the permission check', async () => {
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isOwner: true, hasAccess: true, canEdit: true })
|
||||
);
|
||||
|
||||
await requireProjectAccessOrRedirect({
|
||||
projectId: PROJECT_ID,
|
||||
userId: USER_ID,
|
||||
intent: 'manage',
|
||||
});
|
||||
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, USER_ID, {
|
||||
intent: 'manage',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the session user when no id is passed', async () => {
|
||||
authModule.auth.mockResolvedValue({ user: { id: OTHER_USER_ID } });
|
||||
dbMock.project.findUnique.mockResolvedValue(PROJECT_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isProjectMember: true, hasAccess: true })
|
||||
);
|
||||
|
||||
await requireProjectAccessOrRedirect({ projectId: PROJECT_ID });
|
||||
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
|
||||
intent: 'view',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireVideoProjectAccessOrRedirect', () => {
|
||||
const args = { projectId: PROJECT_ID, videoId: VIDEO_ID };
|
||||
|
||||
it('sends an anonymous caller to the login page before the video is read', async () => {
|
||||
authModule.auth.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(requireVideoProjectAccessOrRedirect(args), LOGIN);
|
||||
expect(dbMock.video.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks the video up inside the project from the URL', async () => {
|
||||
// Without the projectId in the where clause, any video id would resolve
|
||||
// through any project the caller happens to be allowed to see.
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isOwner: true, hasAccess: true })
|
||||
);
|
||||
|
||||
await requireVideoProjectAccessOrRedirect({ ...args, userId: USER_ID });
|
||||
|
||||
expect(dbMock.video.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: VIDEO_ID, projectId: PROJECT_ID } })
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a 404 for a signed-in caller when the video is not in that project', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expectNotFound(requireVideoProjectAccessOrRedirect({ ...args, userId: USER_ID }));
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page rather than a 404 for a missing video', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an anonymous caller to the login page on a public route asking for manage rights', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, intent: 'manage', allowPublicView: true }),
|
||||
LOGIN
|
||||
);
|
||||
expect(authModule.checkProjectAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a signed-in stranger to the dashboard', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(projectAccess({ hasAccess: false }));
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a read-only member to the dashboard when the page needs manage rights', async () => {
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(
|
||||
projectAccess({ isProjectMember: true, hasAccess: true, canEdit: false })
|
||||
);
|
||||
|
||||
await expectRedirect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID, intent: 'manage' }),
|
||||
FORBIDDEN
|
||||
);
|
||||
});
|
||||
|
||||
it('authorizes against the parent project and returns it alongside the video', async () => {
|
||||
const access = projectAccess({ isProjectMember: true, hasAccess: true });
|
||||
dbMock.video.findFirst.mockResolvedValue(VIDEO_ROW);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID })
|
||||
).resolves.toEqual({ video: VIDEO_ROW, project: PROJECT_ROW, access });
|
||||
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
|
||||
intent: 'view',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an anonymous viewer watch a video in a public project when the route opts in', async () => {
|
||||
const publicVideo = { id: VIDEO_ID, project: PUBLIC_PROJECT_ROW };
|
||||
const access = projectAccess({ hasAccess: true });
|
||||
dbMock.video.findFirst.mockResolvedValue(publicVideo);
|
||||
authModule.checkProjectAccess.mockResolvedValue(access);
|
||||
|
||||
await expect(
|
||||
requireVideoProjectAccessOrRedirect({ ...args, allowPublicView: true })
|
||||
).resolves.toEqual({ video: publicVideo, project: PUBLIC_PROJECT_ROW, access });
|
||||
expect(nav.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { resolveWorkspacePermissions } from '@/lib/auth';
|
||||
|
||||
// `@/lib/auth` reaches `@/lib/db`, which opens a pg Pool and registers process
|
||||
// signal handlers on import. resolveWorkspacePermissions touches no database.
|
||||
vi.mock('@/lib/db', () => ({ db: {}, default: {}, disconnectDb: vi.fn() }));
|
||||
|
||||
// The workspace half of the permission matrix. Until this file existed the
|
||||
// formulas were only ever reached through checkWorkspaceAccess(), which means
|
||||
// they were asserted on incidentally by whichever route a suite happened to
|
||||
// call. tests/unit/lib/project-access.test.ts does the same job for projects.
|
||||
//
|
||||
// Every case below states the expected verdict outright rather than deriving it
|
||||
// from the inputs, so a change to the formula cannot quietly change the
|
||||
// expectation with it.
|
||||
|
||||
// There is deliberately no separate 'anonymous' actor. resolveWorkspacePermissions
|
||||
// receives three booleans, not a user, and an anonymous caller and a signed-in
|
||||
// outsider set all three to false, so the two would be byte-identical inputs
|
||||
// running under names that imply a distinction this function cannot see. Telling
|
||||
// "no session" from "a session with no membership" is checkWorkspaceAccess()'s
|
||||
// job: it is the one that resolves a userId to membership rows before calling
|
||||
// here, and it is covered against the database in the api suites.
|
||||
type Actor = 'outsider' | 'member' | 'admin' | 'owner';
|
||||
|
||||
function inputsFor(actor: Actor, ownerBillingActive: boolean) {
|
||||
return {
|
||||
isOwner: actor === 'owner',
|
||||
isMember: actor === 'member' || actor === 'admin',
|
||||
isAdmin: actor === 'admin',
|
||||
ownerBillingActive,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveWorkspacePermissions, with the owner billing active', () => {
|
||||
const cases: Array<{
|
||||
actor: Actor;
|
||||
hasAccess: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}> = [
|
||||
{ actor: 'outsider', hasAccess: false, canEdit: false, canDelete: false },
|
||||
{ actor: 'member', hasAccess: true, canEdit: false, canDelete: false },
|
||||
{ actor: 'admin', hasAccess: true, canEdit: true, canDelete: false },
|
||||
{ actor: 'owner', hasAccess: true, canEdit: true, canDelete: true },
|
||||
];
|
||||
|
||||
for (const { actor, hasAccess, canEdit, canDelete } of cases) {
|
||||
it(`grants a ${actor} access=${hasAccess}, edit=${canEdit}, delete=${canDelete}`, () => {
|
||||
const result = resolveWorkspacePermissions(inputsFor(actor, true));
|
||||
|
||||
expect(result.hasAccess).toBe(hasAccess);
|
||||
expect(result.canEdit).toBe(canEdit);
|
||||
expect(result.canDelete).toBe(canDelete);
|
||||
});
|
||||
}
|
||||
|
||||
it('only the owner can delete, an admin cannot', () => {
|
||||
// Stated separately because it is the one rule that differs from the
|
||||
// project matrix, where a project admin does get canDelete through the
|
||||
// workspace-owner branch.
|
||||
expect(resolveWorkspacePermissions(inputsFor('admin', true)).canDelete).toBe(false);
|
||||
expect(resolveWorkspacePermissions(inputsFor('owner', true)).canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the membership flags it was handed, unchanged', () => {
|
||||
expect(resolveWorkspacePermissions(inputsFor('admin', true))).toEqual({
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
isAdmin: true,
|
||||
hasAccess: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
ownerBillingActive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkspacePermissions, with the owner billing lapsed', () => {
|
||||
// Billing is the outer gate: it revokes everything, including from the owner
|
||||
// of the workspace. A member who kept `hasAccess` here would keep reading a
|
||||
// workspace the account no longer pays for.
|
||||
for (const actor of ['outsider', 'member', 'admin', 'owner'] as const) {
|
||||
it(`refuses a ${actor} everything`, () => {
|
||||
const result = resolveWorkspacePermissions(inputsFor(actor, false));
|
||||
|
||||
expect(result.hasAccess).toBe(false);
|
||||
expect(result.canEdit).toBe(false);
|
||||
expect(result.canDelete).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it('still reports the membership flags, so a caller can tell "lapsed" from "not a member"', () => {
|
||||
const result = resolveWorkspacePermissions(inputsFor('owner', false));
|
||||
|
||||
expect(result.isOwner).toBe(true);
|
||||
expect(result.ownerBillingActive).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user