test(security): add unit tests for cookies.ts CSRF utilities
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m40s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m24s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m35s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 20s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
nightly / deploy-staging (push) Successful in 2m10s

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 <noreply@anthropic.com>
This commit was merged in pull request #695.
This commit is contained in:
Marcel
2026-05-30 11:55:55 +02:00
parent 5d8d85057d
commit 397fc3c7e4

View File

@@ -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<string, string> | 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');
});
});