Corp/frontend/src/lib/api/client.test.js

193 lines
6.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import ts from 'typescript';
const source = await readFile(new URL('./client.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 },
}).outputText;
const {
ApiClientBase,
AuthRecoveryError,
AuthSessionExpiredError,
ApiNetworkError,
} = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
class MemoryStorage {
#values = new Map();
getItem(key) {
return this.#values.get(key) ?? null;
}
setItem(key, value) {
this.#values.set(key, String(value));
}
removeItem(key) {
this.#values.delete(key);
}
clear() {
this.#values.clear();
}
}
function installBrowserGlobals() {
const storage = new MemoryStorage();
const target = new EventTarget();
target.setTimeout = setTimeout;
target.clearTimeout = clearTimeout;
globalThis.window = target;
globalThis.localStorage = storage;
if (typeof globalThis.CustomEvent === 'undefined') {
globalThis.CustomEvent = class CustomEvent extends Event {
constructor(type, init = {}) {
super(type);
this.detail = init.detail;
}
};
}
return storage;
}
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
test('parallel 401 responses share one refresh and both requests resume', async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = originalFetch; });
const storage = installBrowserGlobals();
storage.setItem('access_token', 'expired-access');
storage.setItem('refresh_token', 'valid-refresh');
let refreshCalls = 0;
globalThis.fetch = async (url, options = {}) => {
if (url === '/api/auth/token/refresh/') {
refreshCalls += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return jsonResponse({ access: 'fresh-access' });
}
if (url === '/resource') {
return options.headers.Authorization === 'Bearer fresh-access'
? jsonResponse({ ok: true })
: jsonResponse({ detail: 'expired' }, 401);
}
throw new Error(`Unexpected URL: ${url}`);
};
const client = new ApiClientBase();
const [first, second] = await Promise.all([
client.request('/resource'),
client.request('/resource'),
]);
assert.deepEqual(first, { ok: true });
assert.deepEqual(second, { ok: true });
assert.equal(refreshCalls, 1);
assert.equal(storage.getItem('access_token'), 'fresh-access');
});
test('transient refresh failure rejects every waiter and preserves the session', async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = originalFetch; });
const storage = installBrowserGlobals();
storage.setItem('access_token', 'expired-access');
storage.setItem('refresh_token', 'valid-refresh');
let refreshCalls = 0;
globalThis.fetch = async (url) => {
if (url === '/api/auth/token/refresh/') {
refreshCalls += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return jsonResponse({ detail: 'temporary outage' }, 502);
}
return jsonResponse({ detail: 'expired' }, 401);
};
const client = new ApiClientBase();
const settled = await Promise.race([
Promise.allSettled([client.request('/one'), client.request('/two')]),
new Promise((resolve) => setTimeout(() => resolve('still-pending'), 500)),
]);
assert.notEqual(settled, 'still-pending');
assert.equal(refreshCalls, 1);
for (const result of settled) {
assert.equal(result.status, 'rejected');
assert.ok(result.reason instanceof AuthRecoveryError);
}
assert.equal(storage.getItem('access_token'), 'expired-access');
assert.equal(storage.getItem('refresh_token'), 'valid-refresh');
});
test('confirmed invalid refresh clears tokens and expires the session', async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = originalFetch; });
const storage = installBrowserGlobals();
storage.setItem('access_token', 'expired-access');
storage.setItem('refresh_token', 'invalid-refresh');
globalThis.fetch = async (url) => {
if (url === '/api/auth/token/refresh/') {
return jsonResponse({ detail: 'token_not_valid' }, 401);
}
return jsonResponse({ detail: 'expired' }, 401);
};
const client = new ApiClientBase();
await assert.rejects(client.request('/resource'), AuthSessionExpiredError);
assert.equal(storage.getItem('access_token'), null);
assert.equal(storage.getItem('refresh_token'), null);
});
test('request timeout terminates the request without deleting tokens', async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = originalFetch; });
const storage = installBrowserGlobals();
storage.setItem('access_token', 'access');
storage.setItem('refresh_token', 'refresh');
globalThis.fetch = async (_url, options = {}) => new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
});
const client = new ApiClientBase();
await assert.rejects(
client.request('/slow', { timeoutMs: 20 }),
ApiNetworkError,
);
assert.equal(storage.getItem('access_token'), 'access');
assert.equal(storage.getItem('refresh_token'), 'refresh');
});
test('browser SSO logout does not depend on JWT and includes its cookie', async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = originalFetch; });
const storage = installBrowserGlobals();
storage.setItem('access_token', 'possibly-expired-access');
storage.setItem('refresh_token', 'possibly-expired-refresh');
const requests = [];
globalThis.fetch = async (url, options = {}) => {
requests.push({ url, options });
return new Response(null, { status: 204 });
};
const client = new ApiClientBase();
await client.logoutBrowserSession();
assert.equal(requests.length, 1);
assert.equal(requests[0].url, '/api/auth/browser-session/logout/');
assert.equal(requests[0].options.method, 'POST');
assert.equal(requests[0].options.credentials, 'include');
assert.equal(requests[0].options.headers?.Authorization, undefined);
assert.equal(storage.getItem('access_token'), 'possibly-expired-access');
assert.equal(storage.getItem('refresh_token'), 'possibly-expired-refresh');
});