/** * Integration test: GET /api/storage/[token] * * Exercises the ยง14.9a critical mitigations on the live route: * - HMAC verification: a token signed with the wrong secret is rejected. * - Expiry: an expired token is rejected. * - Single-use replay: a token used twice (within the replay TTL) is * rejected the second time. * - Happy path: a valid token streams the file with correct headers. * * The storage backend itself is mocked to a FilesystemBackend rooted in a * tempdir. Redis is mocked to an in-memory map so the test doesn't need * a live Redis. */ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const VALID_KEY = 'a'.repeat(64); // Hoisted in-memory Redis. The proxy route uses SET NX EX, so we model // just enough behaviour to track keys that have been seen. const redisStore = new Map(); vi.mock('@/lib/redis', () => ({ redis: { set: vi.fn(async (key: string, value: string, ..._args: unknown[]) => { // _args = ['EX', ttl, 'NX'] in our usage. Honour NX semantics. const nxIndex = _args.findIndex((a) => a === 'NX'); if (nxIndex >= 0 && redisStore.has(key)) return null; redisStore.set(key, value); return 'OK'; }), }, })); vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); beforeAll(() => { process.env.EMAIL_CREDENTIAL_KEY = VALID_KEY; process.env.BETTER_AUTH_SECRET = 'a'.repeat(64); }); describe('GET /api/storage/[token]', () => { let storageRoot: string; let backend: import('@/lib/storage/filesystem').FilesystemBackend; let getMock: ReturnType; beforeEach(async () => { redisStore.clear(); storageRoot = await mkdtemp(path.join(tmpdir(), 'pn-storage-route-')); // Use the real FilesystemBackend so the resolution / realpath logic is // genuinely exercised; mock just `getStorageBackend()` to return it. const { FilesystemBackend } = await import('@/lib/storage/filesystem'); backend = await FilesystemBackend.create({ root: storageRoot, proxyHmacSecretEncrypted: null, }); getMock = vi.fn(async () => backend); vi.doMock('@/lib/storage', async () => { const real = await vi.importActual('@/lib/storage'); return { ...real, getStorageBackend: getMock }; }); }); afterEach(async () => { vi.doUnmock('@/lib/storage'); await rm(storageRoot, { recursive: true, force: true }); }); async function callRoute(token: string) { const { GET } = await import('@/app/api/storage/[token]/route'); return GET(new Request(`http://test/api/storage/${token}`) as never, { params: Promise.resolve({ token }), }); } it('serves a file with a valid token (happy path)', async () => { await backend.put('berths/abc/file.txt', Buffer.from('hello world'), { contentType: 'text/plain', }); const presigned = await backend.presignDownload('berths/abc/file.txt', { expirySeconds: 60, filename: 'file.txt', contentType: 'text/plain', }); const token = presigned.url.replace('/api/storage/', ''); const res = await callRoute(token); expect(res.status).toBe(200); expect(res.headers.get('Content-Type')).toBe('text/plain'); expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff'); const text = await res.text(); expect(text).toBe('hello world'); }); it('rejects a token signed with the wrong HMAC secret', async () => { await backend.put('berths/abc/file.txt', Buffer.from('hello'), { contentType: 'text/plain', }); const { signProxyToken } = await import('@/lib/storage/filesystem'); const badToken = signProxyToken( { k: 'berths/abc/file.txt', e: Math.floor(Date.now() / 1000) + 60, n: 'nonce', }, 'wrong-secret', ); const res = await callRoute(badToken); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/Invalid|expired/i); }); it('rejects an expired token', async () => { await backend.put('berths/abc/file.txt', Buffer.from('hello'), { contentType: 'text/plain', }); const { signProxyToken } = await import('@/lib/storage/filesystem'); const expiredToken = signProxyToken( { k: 'berths/abc/file.txt', e: Math.floor(Date.now() / 1000) - 1, n: 'nonce', }, backend.getHmacSecret(), ); const res = await callRoute(expiredToken); expect(res.status).toBe(403); }); it('refuses to replay a token a second time within the TTL', async () => { await backend.put('berths/abc/file.txt', Buffer.from('hello'), { contentType: 'text/plain', }); const presigned = await backend.presignDownload('berths/abc/file.txt', { expirySeconds: 60, }); const token = presigned.url.replace('/api/storage/', ''); const first = await callRoute(token); expect(first.status).toBe(200); await first.text(); const second = await callRoute(token); expect(second.status).toBe(403); const body = await second.json(); expect(body.error).toMatch(/already used/i); }); });