import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME } from './auth.config'; import { csrfInterceptor } from './csrf.interceptor'; const BFF_BASE = 'http://bff.test/api'; const CSRF_COOKIE = 'portal_csrf'; function setup() { TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptors([csrfInterceptor])), provideHttpClientTesting(), { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, { provide: AUTH_CSRF_COOKIE_NAME, useValue: CSRF_COOKIE }, ], }); return { http: TestBed.inject(HttpClient), httpCtrl: TestBed.inject(HttpTestingController), }; } /** * jsdom keeps `document.cookie` as a single mutable string. Wipe it * around each test so prior writes don't leak. */ function setCookie(name: string, value: string): void { document.cookie = `${name}=${value}; path=/`; } function clearAllCookies(): void { const all = document.cookie.split(';'); for (const c of all) { const eq = c.indexOf('='); const name = (eq > -1 ? c.slice(0, eq) : c).trim(); if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; } } describe('csrfInterceptor', () => { beforeEach(() => clearAllCookies()); afterEach(() => { clearAllCookies(); TestBed.resetTestingModule(); }); it('echoes the cookie value into the X-CSRF-Token header on POST', () => { setCookie(CSRF_COOKIE, 'tok-abc'); const { http, httpCtrl } = setup(); http.post(`${BFF_BASE}/profile`, { name: 'Jane' }).subscribe(); const req = httpCtrl.expectOne(`${BFF_BASE}/profile`); expect(req.request.headers.get('X-CSRF-Token')).toBe('tok-abc'); req.flush({}); }); it.each(['PUT', 'PATCH', 'DELETE'])('sets the header on %s too', (method) => { setCookie(CSRF_COOKIE, 'tok-abc'); const { http, httpCtrl } = setup(); http.request(method, `${BFF_BASE}/profile`, { body: {} }).subscribe(); const req = httpCtrl.expectOne(`${BFF_BASE}/profile`); expect(req.request.headers.get('X-CSRF-Token')).toBe('tok-abc'); req.flush({}); }); it('does NOT set the header on GET (BFF skips CSRF on safe methods)', () => { setCookie(CSRF_COOKIE, 'tok-abc'); const { http, httpCtrl } = setup(); http.get(`${BFF_BASE}/me`).subscribe(); const req = httpCtrl.expectOne(`${BFF_BASE}/me`); expect(req.request.headers.has('X-CSRF-Token')).toBe(false); req.flush({}); }); it('does NOT touch requests to other origins', () => { setCookie(CSRF_COOKIE, 'tok-abc'); const { http, httpCtrl } = setup(); http.post('http://otel.example/v1/traces', {}).subscribe(); const req = httpCtrl.expectOne('http://otel.example/v1/traces'); expect(req.request.headers.has('X-CSRF-Token')).toBe(false); req.flush({}); }); it('omits the header when the cookie is missing (lets the BFF respond with its 403)', () => { // No setCookie call — cookie isn't set. const { http, httpCtrl } = setup(); http.post(`${BFF_BASE}/profile`, {}).subscribe(); const req = httpCtrl.expectOne(`${BFF_BASE}/profile`); expect(req.request.headers.has('X-CSRF-Token')).toBe(false); req.flush({}); }); it('URL-decodes the cookie value (cookies are URI-encoded in transit)', () => { // Cookies preserve their stored form; if anything URL-encodes // the token (current BFF doesn't, base64url is safe), we still // round-trip cleanly. setCookie(CSRF_COOKIE, encodeURIComponent('A B+/=')); const { http, httpCtrl } = setup(); http.post(`${BFF_BASE}/profile`, {}).subscribe(); const req = httpCtrl.expectOne(`${BFF_BASE}/profile`); expect(req.request.headers.get('X-CSRF-Token')).toBe('A B+/='); req.flush({}); }); it('finds the right cookie when others are also set', () => { setCookie('other_cookie', 'noise'); setCookie(CSRF_COOKIE, 'tok-abc'); setCookie('yet_another', 'noise'); const { http, httpCtrl } = setup(); http.post(`${BFF_BASE}/profile`, {}).subscribe(); const req = httpCtrl.expectOne(`${BFF_BASE}/profile`); expect(req.request.headers.get('X-CSRF-Token')).toBe('tok-abc'); req.flush({}); }); });