/** * Security: Error Response Sanitization * * Verifies that errorResponse() never leaks stack traces, SQL queries, * internal file paths, or other sensitive server-side details to callers. * * Rule from SECURITY-GUIDELINES.md: * "Error responses must NEVER contain stack traces, SQL queries, or internal paths" */ import { beforeAll, describe, expect, it, vi } from 'vitest'; // ── Mock next/server before importing the module under test ────────────────── // NextResponse is a Next.js runtime class unavailable in a plain Node environment. // We replace it with a minimal shim that captures status + body. vi.mock('next/server', () => { class MockNextResponse { readonly status: number; private body: unknown; constructor(body: unknown, init?: { status?: number }) { this.body = body; this.status = init?.status ?? 200; } async json() { return this.body; } static json(body: unknown, init?: { status?: number }) { return new MockNextResponse(body, init); } } return { NextResponse: MockNextResponse }; }); // Mock the logger so error-level calls don't pollute test output vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), }, })); import { AppError, ForbiddenError, NotFoundError, RateLimitError, ValidationError, errorResponse, } from '@/lib/errors'; // ───────────────────────────────────────────────────────────────────────────── describe('Error response security — AppError subclasses', () => { it('AppError returns correct status without leaking constructor args', async () => { const error = new AppError(400, 'Bad request', 'BAD_REQUEST'); const response = errorResponse(error); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toBe('Bad request'); expect(body.code).toBe('BAD_REQUEST'); // Stack trace must never appear in the response body expect(JSON.stringify(body)).not.toMatch(/at\s+\w+/); // no call-site lines expect(JSON.stringify(body)).not.toContain('node_modules'); }); it('NotFoundError returns 404 with generic message, not entity internals', async () => { const error = new NotFoundError('Client'); const response = errorResponse(error); expect(response.status).toBe(404); const body = await response.json(); expect(body.error).toBe('Client not found'); expect(body.code).toBe('NOT_FOUND'); expect(JSON.stringify(body)).not.toContain('stack'); }); it('ForbiddenError returns 403', async () => { const error = new ForbiddenError(); const response = errorResponse(error); expect(response.status).toBe(403); const body = await response.json(); expect(body.code).toBe('FORBIDDEN'); }); it('RateLimitError returns 429 with retryAfter but no stack', async () => { const error = new RateLimitError(60); const response = errorResponse(error); expect(response.status).toBe(429); const body = await response.json(); expect(body.retryAfter).toBe(60); expect(JSON.stringify(body)).not.toMatch(/stack|node_modules/i); }); it('ValidationError returns 400 with details array, no internal paths', async () => { const error = new ValidationError('Invalid input', [ { field: 'email', message: 'Invalid email format' }, ]); const response = errorResponse(error); expect(response.status).toBe(400); const body = await response.json(); expect(body.details).toHaveLength(1); expect(body.details[0].field).toBe('email'); expect(JSON.stringify(body)).not.toContain('src/'); expect(JSON.stringify(body)).not.toContain('G:\\'); }); }); describe('Error response security — unknown / native errors', () => { it('native Error with SQL content returns generic 500', async () => { const error = new Error( "SELECT * FROM users WHERE id = 1; DROP TABLE users;--", ); const response = errorResponse(error); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('SELECT'); expect(JSON.stringify(body)).not.toContain('DROP TABLE'); }); it('native Error with Windows file path returns generic 500 without path', async () => { const error = new Error( 'at Object. (G:\\Repos\\new-pn-crm\\src\\lib\\db\\index.ts:15:3)', ); const response = errorResponse(error); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('G:\\'); expect(JSON.stringify(body)).not.toContain('src\\lib'); }); it('native Error with node_modules path returns generic 500 without path', async () => { const error = new Error( 'ENOENT: no such file at /app/node_modules/pg/lib/connection.js', ); const response = errorResponse(error); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('node_modules'); expect(JSON.stringify(body)).not.toContain('ENOENT'); }); it('native Error with Postgres relation message returns generic 500', async () => { const error = new Error('relation "users" does not exist'); const response = errorResponse(error); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('relation'); expect(JSON.stringify(body)).not.toContain('"users"'); }); it('null thrown value returns generic 500', async () => { const response = errorResponse(null); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe('Internal server error'); }); it('string thrown returns generic 500', async () => { const response = errorResponse('something went wrong internally'); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe('Internal server error'); // The raw string must not appear in the response expect(JSON.stringify(body)).not.toContain('something went wrong internally'); }); }); describe('Error response security — ZodError', () => { it('ZodError returns 400 with VALIDATION_ERROR code', async () => { const { ZodError, ZodIssueCode } = await import('zod'); const error = new ZodError([ { code: ZodIssueCode.invalid_type, expected: 'string', received: 'number', path: ['name'], message: 'Expected string, received number', }, ]); const response = errorResponse(error); expect(response.status).toBe(400); const body = await response.json(); expect(body.code).toBe('VALIDATION_ERROR'); expect(body.details).toBeDefined(); expect(Array.isArray(body.details)).toBe(true); }); it('ZodError details contain field + message, no internal paths', async () => { const { ZodError, ZodIssueCode } = await import('zod'); const error = new ZodError([ { code: ZodIssueCode.too_small, minimum: 1, type: 'string', inclusive: true, path: ['fullName'], message: 'String must contain at least 1 character(s)', }, ]); const response = errorResponse(error); const body = await response.json(); const bodyStr = JSON.stringify(body); expect(bodyStr).not.toContain('src/'); expect(bodyStr).not.toContain('node_modules'); expect(bodyStr).not.toContain('.ts:'); // The field path is safe to expose (it's user-visible) expect(body.details[0].field).toBe('fullName'); }); }); describe('Error response security — response shape invariants', () => { it('every AppError response body follows { error, code } shape', async () => { const errors = [ new AppError(400, 'Bad request', 'BAD_REQUEST'), new NotFoundError('Invoice'), new ForbiddenError('Cannot delete'), new RateLimitError(30), ]; for (const err of errors) { const body = await errorResponse(err).json(); expect(typeof body.error).toBe('string'); expect(body.error.length).toBeGreaterThan(0); // Stack must never appear expect(body).not.toHaveProperty('stack'); } }); it('500 response body has exactly the error key and nothing else', async () => { const response = errorResponse(new Error('db connection refused')); const body = await response.json(); expect(Object.keys(body)).toEqual(['error']); expect(body.error).toBe('Internal server error'); }); });