feat(dpop): test XHR request + test session refresh

This commit is contained in:
Edouard Vanbelle
2026-08-09 02:25:43 +02:00
parent a218199a02
commit 7529914e32
2 changed files with 299 additions and 3 deletions
+103 -1
View File
@@ -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<typeof import('$lib/auth/dpop-proof')>('$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);
});
});
+196 -2
View File
@@ -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<typeof import('$lib/auth/dpop-proof')>('$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<typeof vi.fn>;
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<string, string>();
responseHeaders = new Map<string, string>();
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<string, string> = {}): 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<MockXHR> {
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
});
});