Files
govoplan-core/webui/tests/api-client-cache.test.mjs
T

215 lines
10 KiB
JavaScript

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");
});