feat(dpop): test XHR request + test session refresh
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user