From 397fc3c7e4ad3e0146dd77480c354cddca987f69 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 30 May 2026 11:55:55 +0200 Subject: [PATCH] test(security): add unit tests for cookies.ts CSRF utilities Covers getCsrfToken (cookie parsing, URL-decoding, server-side null), withCsrf (header injection, immutability, no-op when absent), makeCsrfFetch (method filtering, case-insensitivity, inner-vs-global), and csrfFetch (regression guard: vi.stubGlobal is honoured at call time, not bypassed by a module-level captured reference). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/shared/cookies.test.ts | 233 ++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 frontend/src/lib/shared/cookies.test.ts diff --git a/frontend/src/lib/shared/cookies.test.ts b/frontend/src/lib/shared/cookies.test.ts new file mode 100644 index 00000000..8e09f6a6 --- /dev/null +++ b/frontend/src/lib/shared/cookies.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { getCsrfToken, withCsrf, makeCsrfFetch, csrfFetch } from './cookies'; + +// Helper that builds a minimal document.cookie stub. +function stubDocument(cookieStr: string) { + vi.stubGlobal('document', { cookie: cookieStr }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// getCsrfToken +// --------------------------------------------------------------------------- + +describe('getCsrfToken', () => { + it('returns null when document is undefined (server-side / Node)', () => { + // No stub — document is undefined in Node vitest environment. + expect(getCsrfToken()).toBeNull(); + }); + + it('returns null when XSRF-TOKEN cookie is absent', () => { + stubDocument('other=abc; another=xyz'); + expect(getCsrfToken()).toBeNull(); + }); + + it('returns the token when XSRF-TOKEN is the only cookie', () => { + stubDocument('XSRF-TOKEN=secret42'); + expect(getCsrfToken()).toBe('secret42'); + }); + + it('returns the token when XSRF-TOKEN appears among other cookies', () => { + stubDocument('fa_session=sess1; XSRF-TOKEN=csrf99; locale=de'); + expect(getCsrfToken()).toBe('csrf99'); + }); + + it('URL-decodes the token value', () => { + stubDocument('XSRF-TOKEN=hello%2Fworld'); + expect(getCsrfToken()).toBe('hello/world'); + }); +}); + +// --------------------------------------------------------------------------- +// withCsrf +// --------------------------------------------------------------------------- + +describe('withCsrf', () => { + it('returns an empty RequestInit when cookie is absent and no init given', () => { + // No document stub → getCsrfToken() returns null. + expect(withCsrf()).toEqual({}); + }); + + it('passes init through unchanged when cookie is absent', () => { + const init: RequestInit = { method: 'POST', body: 'data' }; + expect(withCsrf(init)).toEqual(init); + }); + + it('injects X-XSRF-TOKEN header when token is present', () => { + stubDocument('XSRF-TOKEN=tok123'); + const result = withCsrf({ method: 'POST' }); + expect((result.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok123'); + }); + + it('preserves existing headers when injecting the CSRF token', () => { + stubDocument('XSRF-TOKEN=tok123'); + const result = withCsrf({ headers: { 'Content-Type': 'application/json' } }); + const headers = result.headers as Headers; + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-XSRF-TOKEN')).toBe('tok123'); + }); + + it('preserves the rest of the RequestInit when injecting', () => { + stubDocument('XSRF-TOKEN=tok'); + const result = withCsrf({ method: 'PUT', body: '{}' }); + expect(result.method).toBe('PUT'); + expect(result.body).toBe('{}'); + }); + + it('does not mutate the original init object', () => { + stubDocument('XSRF-TOKEN=tok'); + const init: RequestInit = { method: 'POST' }; + withCsrf(init); + expect(init.headers).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// makeCsrfFetch +// --------------------------------------------------------------------------- + +describe('makeCsrfFetch', () => { + const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; + const SAFE = ['GET', 'HEAD'] as const; + + it('wraps the injected fetch — not the global one', async () => { + const inner = vi.fn().mockResolvedValue(new Response()); + const globalMock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', globalMock); + + const wrapped = makeCsrfFetch(inner); + await wrapped('/api/test', { method: 'GET' }); + + expect(inner).toHaveBeenCalledOnce(); + expect(globalMock).not.toHaveBeenCalled(); + }); + + it.each(MUTATING)('calls withCsrf for %s requests', async (method) => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource', { method }); + + const passedInit = inner.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok'); + }); + + it.each(SAFE)('passes %s requests through without CSRF header', async (method) => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource', { method }); + + // init is passed as-is (no headers added). + const passedInit = inner.mock.calls[0][1] as RequestInit; + expect(passedInit?.headers).toBeUndefined(); + }); + + it('defaults to GET semantics when method is omitted', async () => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource'); + + expect(inner).toHaveBeenCalledOnce(); + const passedInit = inner.mock.calls[0][1] as RequestInit | undefined; + expect(passedInit?.headers).toBeUndefined(); + }); + + it('is case-insensitive for method names', async () => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource', { method: 'post' }); + + const passedInit = inner.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok'); + }); +}); + +// --------------------------------------------------------------------------- +// csrfFetch — uses the global fetch (regression guard for the module-const bug) +// --------------------------------------------------------------------------- + +describe('csrfFetch', () => { + const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; + const SAFE = ['GET', 'HEAD'] as const; + + it('picks up a vi.stubGlobal fetch stub — does NOT bypass the mock', async () => { + // This is the regression test for the original module-level-const bug. + // If csrfFetch were `export const csrfFetch = makeCsrfFetch(fetch)` the + // reference captured at module init time would skip any later stub. + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/test', { method: 'GET' }); + + expect(mock).toHaveBeenCalledOnce(); + }); + + it.each(MUTATING)('injects X-XSRF-TOKEN for %s when cookie is set', async (method) => { + stubDocument('XSRF-TOKEN=csrf-val'); + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('csrf-val'); + }); + + it.each(SAFE)('does NOT inject CSRF header for %s', async (method) => { + stubDocument('XSRF-TOKEN=csrf-val'); + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + expect(passedInit?.headers).toBeUndefined(); + }); + + it('does not inject header when XSRF-TOKEN cookie is absent', async () => { + // No document stub → getCsrfToken() returns null → withCsrf() is a no-op. + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method: 'POST' }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + // withCsrf returns { ...init } when no token — headers key won't be set. + const headers = passedInit.headers; + if (headers instanceof Headers) { + expect(headers.has('X-XSRF-TOKEN')).toBe(false); + } else { + expect((headers as Record | undefined)?.['X-XSRF-TOKEN']).toBeUndefined(); + } + }); + + it('forwards the URL and init body to fetch', async () => { + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method: 'GET', body: null }); + + expect(mock.mock.calls[0][0]).toBe('/api/resource'); + }); + + it('is case-insensitive for method names', async () => { + stubDocument('XSRF-TOKEN=tok'); + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method: 'delete' }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok'); + }); +});