import type { NextFunction, Request, Response } from 'express'; import type { Logger } from 'nestjs-pino'; import { createCsrfMiddleware } from './csrf.middleware'; interface SessionStub { user?: { oid: string }; csrfToken?: string; } function makeReq(opts: { method: string; path: string; headers?: Record; session?: SessionStub; }): Request { const headers = opts.headers ?? {}; return { method: opts.method, path: opts.path, session: opts.session, get: (name: string) => headers[name], } as unknown as Request; } function makeRes() { const res = { status: jest.fn(), json: jest.fn(), }; res.status.mockReturnValue(res); res.json.mockReturnValue(res); return res as unknown as Response & { status: jest.Mock; json: jest.Mock }; } function makeLogger() { return { log: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), } as unknown as Logger & { warn: jest.Mock }; } describe('csrf middleware', () => { const next: NextFunction = jest.fn(); beforeEach(() => (next as jest.Mock).mockReset()); describe('skip cases', () => { it.each(['GET', 'HEAD', 'OPTIONS'])('passes through safe method %s', (method) => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method, path: '/api/anything' }); const res = makeRes(); csrf(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(res.status).not.toHaveBeenCalled(); }); it('passes through anonymous mutating requests (no req.session.user)', () => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method: 'POST', path: '/api/public', session: {} }); const res = makeRes(); csrf(req, res, next); expect(next).toHaveBeenCalledTimes(1); }); it('passes through /api/auth/login (CSRF-exempt: session is born here)', () => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method: 'POST', path: '/api/auth/login', session: { user: { oid: 'u' }, csrfToken: 'irrelevant' }, }); const res = makeRes(); csrf(req, res, next); expect(next).toHaveBeenCalledTimes(1); }); it('passes through /api/auth/callback (CSRF-exempt)', () => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method: 'POST', path: '/api/auth/callback', session: { user: { oid: 'u' }, csrfToken: 'irrelevant' }, }); const res = makeRes(); csrf(req, res, next); expect(next).toHaveBeenCalledTimes(1); }); }); describe('enforcement', () => { it('rejects with 403 when an authenticated mutation lacks X-CSRF-Token', () => { const logger = makeLogger(); const csrf = createCsrfMiddleware(logger); const req = makeReq({ method: 'POST', path: '/api/profile', session: { user: { oid: 'u' }, csrfToken: 'expected-token' }, }); const res = makeRes(); csrf(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: 'csrf' }); expect(next).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ event: 'csrf.reject', hasHeaderToken: false }), 'Csrf', ); }); it('rejects with 403 when the header value mismatches the session token', () => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method: 'POST', path: '/api/profile', headers: { 'X-CSRF-Token': 'wrong-token' }, session: { user: { oid: 'u' }, csrfToken: 'expected-token' }, }); const res = makeRes(); csrf(req, res, next); expect(res.status).toHaveBeenCalledWith(403); }); it('rejects with 403 when the session has no csrfToken (auth without CSRF — should never happen)', () => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method: 'POST', path: '/api/profile', headers: { 'X-CSRF-Token': 'anything' }, session: { user: { oid: 'u' } }, }); const res = makeRes(); csrf(req, res, next); expect(res.status).toHaveBeenCalledWith(403); }); it('passes when header matches the session token exactly', () => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method: 'POST', path: '/api/profile', headers: { 'X-CSRF-Token': 'expected-token' }, session: { user: { oid: 'u' }, csrfToken: 'expected-token' }, }); const res = makeRes(); csrf(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(res.status).not.toHaveBeenCalled(); }); it.each(['PUT', 'PATCH', 'DELETE'])('enforces on mutation method %s like POST', (method) => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method, path: '/api/profile', headers: { 'X-CSRF-Token': 'tok' }, session: { user: { oid: 'u' }, csrfToken: 'tok' }, }); const res = makeRes(); csrf(req, res, next); expect(next).toHaveBeenCalledTimes(1); }); it('uses constant-time comparison (different lengths short-circuit as unequal)', () => { const csrf = createCsrfMiddleware(makeLogger()); const req = makeReq({ method: 'POST', path: '/api/profile', headers: { 'X-CSRF-Token': 'short' }, session: { user: { oid: 'u' }, csrfToken: 'much-longer-token-value' }, }); const res = makeRes(); csrf(req, res, next); expect(res.status).toHaveBeenCalledWith(403); }); }); });