import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import test from "node:test"; import vm from "node:vm"; const require = createRequire(import.meta.url); const { transformSync } = require("esbuild"); const code = transformSync(readFileSync(new URL("../src/api/client.ts", import.meta.url), "utf8"), { loader: "ts", format: "cjs", target: "es2022", define: { "import.meta.env": "{}" } }).code; const settings = { apiBaseUrl: "https://fixture.invalid", accessToken: "", apiKey: "" }; const json = (value, headers = {}) => new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json", ...headers } }); const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }; function harness(respond) { const requests = []; const document = { cookie: "govoplan_csrf=session-one" }; const storage = new Map(); const events = []; let now = 1000; const context = vm.createContext({ Headers, Response, FormData, URL, URLSearchParams, AbortController, CustomEvent, console, window: { dispatchEvent: (event) => events.push(event) }, Date: { now: () => now }, document, localStorage: { setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) }, sessionStorage: { setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) }, fetch: (url, init) => { requests.push({ url, ...init }); return respond(requests.at(-1), requests.length); }, module: { exports: {} }, require: (name) => { assert.equal(name, "../platform/temporal"); return { temporalRequestHeaders: () => ({}) }; } }); context.exports = context.module.exports; vm.runInContext(code, context); return { api: context.module.exports, requests, document, events, tick: (ms = 1000) => { now += ms; } }; } test("identical simultaneous reads share one request and permitted recent reads reuse it", async () => { const response = deferred(); const { api, requests } = harness(() => response.promise); const first = api.apiFetch(settings, "/records"); const second = api.apiFetch(settings, "/records"); assert.equal(requests.length, 1); response.resolve(json("ready")); assert.deepEqual(await Promise.all([first, second]), ["ready", "ready"]); assert.equal(await api.apiFetch(settings, "/records"), "ready"); assert.equal(requests.length, 1); }); for (const directive of ["no-store", "private, NO-STORE", "no-cache", "private, max-age=0", "max-age=\"0\""]) { test(`response ${directive} never bypasses a fresh server check`, async () => { const { api, requests } = harness((_request, count) => json(count, { "Cache-Control": directive, ETag: '"same"' })); assert.equal(await api.apiFetch(settings, "/records"), 1); assert.equal(await api.apiFetch(settings, "/records"), 2); if (/no-store/i.test(directive)) assert.equal(requests[1].headers.has("If-None-Match"), false); }); } test("no-cache responses retain ETags for authorized 304 revalidation, not recent reuse", async () => { const { api, requests } = harness((_request, count) => count === 1 ? json("ready", { "Cache-Control": "private, no-cache", ETag: '"same"' }) : new Response(null, { status: 304, headers: { "Cache-Control": "private, no-cache", ETag: '"same"' } })); await api.apiFetch(settings, "/records"); assert.equal(await api.apiFetch(settings, "/records"), "ready"); assert.equal(await api.apiFetch(settings, "/records"), "ready"); assert.equal(requests.length, 3); assert.equal(requests[1].headers.get("If-None-Match"), '"same"'); }); test("late pre-mutation reads cannot repopulate either cache", async () => { const pending = deferred(); const { api, requests, tick } = harness((_request, count) => count === 1 ? pending.promise : json("fresh")); const oldRead = api.apiFetch(settings, "/records"); await api.apiFetch(settings, "/records", { method: "POST", body: "{}" }); pending.resolve(json("old", { ETag: '"old"' })); await oldRead; tick(); assert.equal(await api.apiFetch(settings, "/records"), "fresh"); assert.equal(requests.at(-1).headers.has("If-None-Match"), false); }); test("reads completed during a mutation are invalidated when that mutation completes", async () => { const pending = deferred(); const { api, requests } = harness((request) => request.method === "POST" ? pending.promise : json("read", { ETag: '"during"' })); const write = api.apiFetch(settings, "/records", { method: "POST", body: "{}" }); await api.apiFetch(settings, "/records"); pending.resolve(json("saved")); await write; await api.apiFetch(settings, "/records"); assert.equal(requests.length, 3); assert.equal(requests.at(-1).headers.has("If-None-Match"), false); }); test("a 304 arriving after invalidation cannot resurrect the old response", async () => { const pending = deferred(); const { api, requests, tick } = harness((_request, count) => count === 1 ? json("old", { ETag: '"old"' }) : count === 2 ? pending.promise : json("fresh")); await api.apiFetch(settings, "/records"); tick(); const revalidate = api.apiFetch(settings, "/records"); await api.apiFetch(settings, "/records", { method: "POST", body: "{}" }); pending.resolve(new Response(null, { status: 304 })); await revalidate; assert.equal(await api.apiFetch(settings, "/records"), "fresh"); assert.equal(requests.length, 4); }); test("cookie session changes cannot reuse old response bodies or in-flight requests", async () => { const pending = deferred(); const { api, requests, document, tick } = harness((_request, count) => count === 1 ? pending.promise : json("new-session")); const oldRead = api.apiFetch(settings, "/records"); document.cookie = "govoplan_csrf=session-two"; assert.equal(await api.apiFetch(settings, "/records"), "new-session"); pending.resolve(json("old-session", { ETag: '"old"' })); await oldRead; tick(); await api.apiFetch(settings, "/records"); assert.equal(requests.at(-1).headers.has("If-None-Match"), false); }); for (const transition of ["clearApiReadCache", "saveApiSettings", "clearAccessToken"]) { test(`${transition} discards response bodies across authentication transitions`, async () => { const { api, requests } = harness((_request, count) => json(count, { ETag: '"old"' })); await api.apiFetch(settings, "/records"); api[transition](settings); assert.equal(await api.apiFetch(settings, "/records"), 2); assert.equal(requests.at(-1).headers.has("If-None-Match"), false); }); } test("401 clears cached data without needing a successful logout", async () => { const { api, requests } = harness((_request, count) => count === 2 ? new Response("expired", { status: 401 }) : json(count, { ETag: '"old"' })); await api.apiFetch(settings, "/records"); await assert.rejects(api.apiFetch(settings, "/session"), { status: 401 }); assert.equal(await api.apiFetch(settings, "/records"), 3); assert.equal(requests.at(-1).headers.has("If-None-Match"), false); }); test("explicit no-cache reads bypass recent reuse", async () => { const { api, requests } = harness((_request, count) => json(count)); await api.apiFetch(settings, "/records"); assert.equal(await api.apiFetch(settings, "/records", { cache: "no-cache" }), 2); assert.equal(await api.apiFetch(settings, "/records", { headers: { "Cache-Control": "no-cache" } }), 3); assert.equal(requests.length, 3); }); test("no-store refresh evicts old reusable data for that URL", async () => { const { api, requests } = harness((_request, count) => json(count, { ETag: '"old"' })); await api.apiFetch(settings, "/records"); await api.apiFetch(settings, "/records", { cache: "no-store" }); assert.equal(await api.apiFetch(settings, "/records"), 3); assert.equal(requests.at(-1).headers.has("If-None-Match"), false); }); for (const directive of ["no-store", "no-cache", "max-age=0"]) { test(`header-driven ${directive} refresh supersedes old reads and stored data`, async () => { const pending = deferred(); const { api, requests } = harness((_request, count) => count === 1 ? pending.promise : json(count)); const old = api.apiFetch(settings, "/records"); await api.apiFetch(settings, "/records", { headers: { "Cache-Control": directive } }); pending.resolve(json("old", { ETag: '"old"' })); await old; assert.equal(await api.apiFetch(settings, "/records"), 3); assert.equal(requests.at(-1).headers.has("If-None-Match"), false); }); } for (const download of [false, true]) { test(`old ${download ? "download" : "read"} 401 cannot expire a newer session`, async () => { const pending = deferred(); const { api, requests, events } = harness((_request, count) => count === 1 ? pending.promise : json("new")); const old = download ? api.apiDownload(settings, "/export", "fixture.json") : api.apiFetch(settings, "/records"); api.clearApiReadCache(); await api.apiFetch(settings, "/records"); pending.resolve(new Response("old session expired", { status: 401 })); await assert.rejects(old, { status: 401 }); assert.equal(events.length, 0); assert.equal(await api.apiFetch(settings, "/records"), "new"); assert.equal(requests.length, 2); }); } test("interactive login and logout clear automation credentials instead of shadowing cookie auth", () => { const { api } = harness(() => { throw new Error("No network expected"); }); const keyed = { ...settings, apiKey: "fixture-automation-key", accessToken: "fixture-bearer" }; for (const next of [{ principal: { auth_method: "session" } }, {}]) { const loggedIn = api.apiSettingsForAuthUpdate(keyed, next, ""); assert.equal(loggedIn.apiKey, ""); assert.equal(loggedIn.accessToken, ""); assert.equal([...api.authHeaders(loggedIn)].length, 0); api.saveApiSettings(loggedIn); } const loggedOut = api.apiSettingsForAuthUpdate(keyed, null); assert.equal(loggedOut.apiKey, ""); assert.equal(loggedOut.accessToken, ""); const cookieUpdate = api.apiSettingsForAuthUpdate(keyed, { principal: { auth_method: "session" } }); assert.equal(cookieUpdate.apiKey, ""); assert.equal(api.apiSettingsForAuthUpdate(keyed, { principal: { auth_method: "api_key" } }), keyed); assert.equal(api.apiSettingsForAuthUpdate(keyed, { user: { display_name: "Updated name" } }), keyed); assert.equal(api.apiSettingsForAuthUpdate(settings, { principal: { auth_method: "session" } }), settings, "unchanged settings must keep their identity to avoid profile-fetch effect loops"); });