193 lines
6.4 KiB
JavaScript
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');
|
|
});
|