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