0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
155 lines
6.9 KiB
TypeScript
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);
|
|
});
|