215 lines
10 KiB
JavaScript
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");
|
|
});
|