From 7529914e321616daa8b5c82b684764c13abaed1c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 02:25:43 +0200 Subject: [PATCH] feat(dpop): test XHR request + test session refresh --- frontend/src/lib/api/endpoints/auth.test.ts | 104 +++++++++- frontend/src/lib/api/endpoints/files.test.ts | 198 ++++++++++++++++++- 2 files changed, 299 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 648f9c6d..8bc301f3 100644 --- a/frontend/src/lib/api/endpoints/auth.test.ts +++ b/frontend/src/lib/api/endpoints/auth.test.ts @@ -1,6 +1,19 @@ -import { it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) })); +// Several probes (`fetchMe`, `tryRefresh`) run through the DPoP shim. +// Default `proof = null` matches jsdom's missing WebCrypto keys — so +// pre-existing suite behaviour is unchanged. Individual tests flip the +// state to a non-null value to exercise the DPoP-attached path. +const dpopState = vi.hoisted(() => ({ proof: null as string | null })); +vi.mock('$lib/auth/dpop-proof', async () => { + const actual = + await vi.importActual('$lib/auth/dpop-proof'); + return { + ...actual, + buildDpopProof: vi.fn(async () => dpopState.proof) + }; +}); // Mock the OPAQUE WASM client so `login()`'s Phase 2 silent-migration // hook and Phase 3 lookup-then-login flip can exercise the wire path // (params + lookup + register or ke1/ke3 handshake) without touching @@ -46,6 +59,9 @@ beforeEach(() => { // module-level singleton. Reset it so each test's mock // responses drive a fresh /params fetch. __resetOpaqueParamsCache(); + // Default: DPoP proof unavailable (matches jsdom's missing WebCrypto). + // DPoP-aware describe blocks below opt in by setting a proof value. + dpopState.proof = null; f.mockResolvedValue(okRes); j.mockResolvedValue({}); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes)); @@ -432,3 +448,89 @@ it('login returns AuthResponse even when silent-migration fails (non-fatal)', as expect(authResponse.access_token).toBe('at'); consoleSpy.mockRestore(); }); + +// ── tryRefresh — DPoP wiring ───────────────────────────────────────── +// +// Startup probe (raw `fetch`, NOT apiFetch) that must still authenticate +// under `DPOP=required`: page reloads with a bound session + expired +// access token would otherwise 401. Mirrors `fetchMe`'s DPoP handling: +// dynamic-import proof module, attach `DPoP` header, harvest response +// nonce into the shared cache, retry ONCE on `use_dpop_nonce`. +// +// Regression risks these tests guard: +// - Proof stops being attached → bound sessions can't refresh. +// - Nonce not harvested → the next request 401s with `nonce_missing`. +// - Second challenge loops (would burn cycles and mask a real server +// bug behind an infinite retry). +describe('tryRefresh — DPoP wiring', () => { + const okRefreshRes = () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({}) + }); + const nonceChallenge = () => ({ + ok: false, + status: 401, + headers: new Headers({ + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + }), + json: async () => ({}) + }); + + beforeEach(() => { + // Opt into DPoP-attached behaviour for this block; individual + // tests can still flip it back to null to check the fail-open. + dpopState.proof = 'proof.abc'; + }); + + it('attaches a DPoP header on the refresh POST', async () => { + const spy = vi.fn().mockResolvedValue(okRefreshRes()); + vi.stubGlobal('fetch', spy); + + const ok = await auth.tryRefresh(); + expect(ok).toBe(true); + + const [url, init] = spy.mock.calls[0]; + expect(url).toBe('/api/auth/refresh'); + const hdrs = new Headers((init as RequestInit).headers ?? {}); + expect(hdrs.get('DPoP')).toBe('proof.abc'); + }); + + it('sends no DPoP header when the proof module has no keypair (fail-open)', async () => { + dpopState.proof = null; + const spy = vi.fn().mockResolvedValue(okRefreshRes()); + vi.stubGlobal('fetch', spy); + + await auth.tryRefresh(); + + const [, init] = spy.mock.calls[0]; + const hdrs = new Headers((init as RequestInit).headers ?? {}); + expect(hdrs.get('DPoP')).toBeNull(); + }); + + it('retries once on a use_dpop_nonce challenge, then succeeds', async () => { + const spy = vi + .fn() + .mockResolvedValueOnce(nonceChallenge()) + .mockResolvedValueOnce(okRefreshRes()); + vi.stubGlobal('fetch', spy); + + const ok = await auth.tryRefresh(); + expect(ok).toBe(true); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('does not loop when the retry ALSO returns use_dpop_nonce', async () => { + // A second challenge would indicate a server-side nonce bug; + // tryRefresh must surface it as a plain refresh failure rather + // than looping forever. + const spy = vi.fn().mockResolvedValue(nonceChallenge()); + vi.stubGlobal('fetch', spy); + + const ok = await auth.tryRefresh(); + expect(ok).toBe(false); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/lib/api/endpoints/files.test.ts b/frontend/src/lib/api/endpoints/files.test.ts index 29a2c93f..06c00157 100644 --- a/frontend/src/lib/api/endpoints/files.test.ts +++ b/frontend/src/lib/api/endpoints/files.test.ts @@ -1,6 +1,19 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) })); +// Controllable DPoP proof — `uploadFileWithProgress` uses XHR (needed +// for upload-progress events) and manually signs a proof. Default null +// so pre-existing tests are unaffected; the XHR-DPoP block below opts +// in. +const dpopState = vi.hoisted(() => ({ proof: null as string | null })); +vi.mock('$lib/auth/dpop-proof', async () => { + const actual = + await vi.importActual('$lib/auth/dpop-proof'); + return { + ...actual, + buildDpopProof: vi.fn(async () => dpopState.proof) + }; +}); import { apiFetch } from '$lib/api/client'; import { uploadFile, @@ -8,7 +21,8 @@ import { moveFile, deleteFile, fileDownloadUrl, - fileInlineUrl + fileInlineUrl, + uploadFileWithProgress } from './files'; const f = apiFetch as unknown as ReturnType; describe('files endpoint URL builders', () => { @@ -32,3 +46,183 @@ describe('files endpoint mutations', () => { expect(f).toHaveBeenCalled(); }); }); + +// ── uploadFileWithProgress — DPoP + XHR ────────────────────────────── +// +// The upload path uses raw XMLHttpRequest (fetch can't emit upload- +// progress events). That means it bypasses the `apiFetch` DPoP +// interceptor and has to sign a proof + handle a `use_dpop_nonce` +// challenge itself. These tests guard: +// - DPoP header is attached before send. +// - Progress callback fires from `upload.onprogress`. +// - Nonce is harvested from the response into the shared cache. +// - A `use_dpop_nonce` challenge on the first attempt triggers ONE +// retry (fresh XHR — the failed one already consumed its body). +// - A second challenge does NOT loop (would mask a server-side bug). +// - A 507 rejection carries `isQuota: true` for the batch orchestrator. + +/** + * Minimal `XMLHttpRequest` stand-in — implements only the surface + * `uploadFileWithProgress` touches. Tests drive it by calling + * `respond(status, headers)` / `fireProgress()` / `fireError()`. + */ +class MockXHR { + method = ''; + url = ''; + withCredentials = false; + requestHeaders = new Map(); + responseHeaders = new Map(); + status = 0; + body: unknown = null; + upload: { + onprogress: ((e: ProgressEvent) => void) | null; + onload: (() => void) | null; + } = { onprogress: null, onload: null }; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + setRequestHeader(k: string, v: string): void { + this.requestHeaders.set(k, v); + } + send(body: unknown): void { + this.body = body; + } + abort(): void { + queueMicrotask(() => this.onabort?.()); + } + getResponseHeader(k: string): string | null { + return this.responseHeaders.get(k.toLowerCase()) ?? null; + } + // Test helpers + fireProgress(loaded: number, total: number): void { + this.upload.onprogress?.({ loaded, total, lengthComputable: true } as ProgressEvent); + } + respond(status: number, headers: Record = {}): void { + this.status = status; + for (const [k, v] of Object.entries(headers)) this.responseHeaders.set(k.toLowerCase(), v); + this.onload?.(); + } + fireError(): void { + this.onerror?.(); + } +} + +/** Yield to microtasks + timers so `uploadFileWithProgress`'s dynamic- + * import + `buildDpopProof` chain resolves and `xhr.send()` is reached. */ +async function waitForXhr(sink: MockXHR[], idx = 0, tries = 30): Promise { + for (let i = 0; i < tries; i++) { + if (sink[idx] && sink[idx].body !== null) return sink[idx]; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`XHR #${idx} never reached send() (had ${sink.length} instance(s))`); +} + +describe('uploadFileWithProgress — DPoP + XHR', () => { + let xhrs: MockXHR[]; + beforeEach(() => { + vi.clearAllMocks(); + dpopState.proof = 'proof.upload'; + xhrs = []; + const XhrStub = class extends MockXHR { + constructor() { + super(); + xhrs.push(this); + } + }; + vi.stubGlobal('XMLHttpRequest', XhrStub as unknown as typeof XMLHttpRequest); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('attaches a DPoP header on the XHR and resolves on 2xx', async () => { + const file = new File([new Uint8Array([1, 2, 3])], 'a.txt'); + const onProgress = vi.fn(); + const p = uploadFileWithProgress('folder-1', file, onProgress); + const xhr = await waitForXhr(xhrs); + + expect(xhr.method).toBe('POST'); + expect(xhr.url).toBe('/api/files/upload'); + expect(xhr.requestHeaders.get('DPoP')).toBe('proof.upload'); + expect(xhr.withCredentials).toBe(true); + + // Simulate progress + successful completion. + xhr.fireProgress(1, 3); + xhr.fireProgress(3, 3); + xhr.respond(200); + await expect(p).resolves.toBeUndefined(); + expect(onProgress).toHaveBeenCalled(); + expect(onProgress).toHaveBeenLastCalledWith(1); + }); + + it('sends no DPoP header when the proof module has no keypair (fail-open)', async () => { + dpopState.proof = null; + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + const xhr = await waitForXhr(xhrs); + expect(xhr.requestHeaders.get('DPoP')).toBeUndefined(); + xhr.respond(200); + await p; + }); + + it('surfaces a 507 with isQuota flag for the batch orchestrator', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + const xhr = await waitForXhr(xhrs); + xhr.respond(507); + await expect(p).rejects.toMatchObject({ + isQuota: true, + message: expect.stringContaining('507') + }); + }); + + it('rejects on network error', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + const xhr = await waitForXhr(xhrs); + xhr.fireError(); + await expect(p).rejects.toThrow(/network error/); + }); + + it('retries once on a use_dpop_nonce challenge (fresh XHR)', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + + // First XHR — server sends the DPoP-Nonce challenge. + const first = await waitForXhr(xhrs, 0); + first.respond(401, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + }); + + // Retry mints a fresh XHR (the first one has already consumed + // its body); it must also carry the DPoP header. + const second = await waitForXhr(xhrs, 1); + expect(second.requestHeaders.get('DPoP')).toBe('proof.upload'); + second.respond(200); + await expect(p).resolves.toBeUndefined(); + expect(xhrs).toHaveLength(2); + }); + + it('does not loop when the retry ALSO returns use_dpop_nonce', async () => { + const file = new File([new Uint8Array([1])], 'a.txt'); + const p = uploadFileWithProgress(null, file, () => {}); + + const first = await waitForXhr(xhrs, 0); + first.respond(401, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + }); + const second = await waitForXhr(xhrs, 1); + second.respond(401, { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresher' + }); + + await expect(p).rejects.toThrow(/dpop_nonce_challenge/); + expect(xhrs).toHaveLength(2); // never a third + }); +});