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
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:
233
frontend/src/lib/shared/cookies.test.ts
Normal file
233
frontend/src/lib/shared/cookies.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user