Files
jobtrackingapp/job-tracker-ui/src/session-boundary.test.ts
T
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

155 lines
6.9 KiB
TypeScript

import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
type Harness = { api: typeof import("./api").api; boundary: typeof import("./sessionBoundary") };
let harness: Harness;
let listeners: Array<[string, EventListenerOrEventListenerObject]>;
let addListener: jest.SpyInstance;
function cookie(value: string) { document.cookie = `XSRF-TOKEN=${value}; path=/`; }
function response(config: InternalAxiosRequestConfig, data: unknown): AxiosResponse {
return { config, data, status: 200, statusText: "OK", headers: {} };
}
async function bindA() {
cookie("csrf-a");
harness.api.defaults.adapter = async config => response(config, { id: "user-a", sessionContext: "session-a" });
await harness.api.get("/auth/me");
}
beforeEach(() => {
jest.useFakeTimers();
window.localStorage.clear();
document.cookie = "XSRF-TOKEN=; max-age=0; path=/";
listeners = [];
const originalAdd = window.addEventListener.bind(window);
addListener = jest.spyOn(window, "addEventListener").mockImplementation((type, callback, options) => {
listeners.push([type, callback]);
originalAdd(type, callback, options);
});
jest.isolateModules(() => {
// setupTests keeps its API mock for UI tests; these tests explicitly exercise real Axios.
harness = { api: jest.requireActual("./api").api, boundary: jest.requireActual("./sessionBoundary") };
});
});
afterEach(() => {
for (const [type, callback] of listeners) window.removeEventListener(type, callback);
addListener.mockRestore();
jest.clearAllTimers();
jest.useRealTimers();
});
test("writes carry the page's server-issued context and preserve the same account", async () => {
await bindA();
const calls: InternalAxiosRequestConfig[] = [];
harness.api.defaults.adapter = async config => { calls.push(config); return response(config, {}); };
await harness.api.put("/career/profile", { profile: { contact: { fullName: "A edit" } } });
expect(calls).toHaveLength(1);
expect(calls[0].headers.get("X-Session-Context")).toBe("session-a");
expect(calls[0].headers.get("X-CSRF-TOKEN")).toBe("csrf-a");
expect(harness.boundary.getSessionContext()).toBe("session-a");
});
test("cookie switch rejects a stale callback before transport and never adopts B's context", async () => {
await bindA();
const adapter = jest.fn(async config => response(config, {}));
harness.api.defaults.adapter = adapter;
const queuedSave = () => harness.api.put("/career/profile", { profile: { contact: { fullName: "A retained draft" } } });
cookie("csrf-b");
await expect(queuedSave()).rejects.toMatchObject({ code: "ERR_CANCELED" });
expect(adapter).not.toHaveBeenCalled();
expect(harness.boundary.isSessionInvalidated()).toBe(true);
expect(harness.boundary.bindSession("user-b", "session-b")).toBe(false);
expect(harness.boundary.getSessionContext()).toBeNull();
});
test.each([
["authUserKey", "user-b"],
["authUserKey", null],
["authSessionContext", "new-session-for-same-user"],
[null, null],
])("cross-tab storage event %s invalidates and aborts outstanding requests", async (key, newValue) => {
await bindA();
window.dispatchEvent(new StorageEvent("storage", { key, newValue }));
expect(harness.boundary.isSessionInvalidated()).toBe(true);
expect(harness.boundary.sessionRequestSignal().aborted).toBe(true);
});
test("another session cannot be adopted even if the readable cookie signal is unchanged", async () => {
await bindA();
harness.api.defaults.adapter = async config => response(config, { id: "user-b", sessionContext: "session-b" });
await expect(harness.api.get("/auth/me")).rejects.toMatchObject({ code: "ERR_CANCELED" });
expect(harness.boundary.getSessionContext()).toBeNull();
});
test("same-account reauthentication also discards the old page", async () => {
await bindA();
expect(harness.boundary.bindSession("user-a", "session-a-new")).toBe(false);
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});
test("a delayed private response cannot repopulate caches after invalidation", async () => {
await bindA();
let finish!: (value: AxiosResponse) => void;
let request!: InternalAxiosRequestConfig;
harness.api.defaults.adapter = config => { request = config; return new Promise(resolve => { finish = resolve; }); };
const read = harness.api.get("/companies");
cookie("csrf-b");
window.dispatchEvent(new Event("focus"));
finish(response(request, [{ id: 1, name: "Private A company" }]));
await expect(read).rejects.toMatchObject({ code: "ERR_CANCELED" });
});
test.each([false, true])("a late bootstrap read/error cannot overwrite or sign out a newly bound session (error=%s)", async failure => {
cookie("csrf-a");
let finish!: (value: AxiosResponse) => void;
let fail!: (reason: unknown) => void;
let oldRequest!: InternalAxiosRequestConfig;
harness.api.defaults.adapter = config => {
if (config.url === "/companies") {
oldRequest = config;
return new Promise((resolve, reject) => { finish = resolve; fail = reject; });
}
return Promise.resolve(response(config, { id: "user-b", sessionContext: "session-b" }));
};
const oldRead = harness.api.get("/companies");
cookie("csrf-b");
await harness.api.get("/auth/me");
if (failure) fail(new AxiosError("Expired old session", undefined, oldRequest, undefined,
{ ...response(oldRequest, {}), status: 401 }));
else finish(response(oldRequest, [{ name: "A private company" }]));
await expect(oldRead).rejects.toMatchObject({ code: "ERR_CANCELED" });
expect(harness.boundary.getSessionContext()).toBe("session-b");
expect(window.localStorage.getItem("authUserKey")).toBe("user-b");
expect(harness.boundary.isSessionInvalidated()).toBe(false);
});
test("server precondition failure clears the page without silently retrying a mutation", async () => {
await bindA();
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw new AxiosError("Session mismatch", undefined, config, undefined,
{ ...response(config, { code: "session_context_mismatch" }), status: 409 });
});
harness.api.defaults.adapter = adapter;
await expect(harness.api.post("/cv/variants", { name: "A draft" })).rejects.toBeDefined();
expect(adapter).toHaveBeenCalledTimes(1);
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});
test("BFCache restoration invalidates old private state", async () => {
await bindA();
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});
test("anonymous public-page history does not require a new login", () => {
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
expect(harness.boundary.isSessionInvalidated()).toBe(false);
});
test("cookie fallback still works without a cross-tab storage event", async () => {
await bindA();
cookie("csrf-b");
jest.advanceTimersByTime(1000);
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});