Files
ctms/frontend/src/session/sessionManager.test.ts
T
2026-07-07 11:11:43 +08:00

178 lines
6.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
vi.mock("../router", () => ({
default: {
replace: vi.fn(),
},
}));
const channelPostMessage = vi.fn();
const runtimeMocks = vi.hoisted(() => ({
isTauriRuntime: vi.fn(() => false),
}));
vi.mock("../runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("../runtime")>();
return {
...actual,
isTauriRuntime: runtimeMocks.isTauriRuntime,
};
});
vi.mock("../api/authClient", () => ({
extendToken: vi.fn(),
}));
const encodeJson = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
const createJwt = (expiresAtMs: number): string =>
`${encodeJson({ alg: "none", typ: "JWT" })}.${encodeJson({ exp: Math.floor(expiresAtMs / 1000) })}.signature`;
describe("session manager idle logout", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
runtimeMocks.isTauriRuntime.mockReturnValue(false);
vi.useFakeTimers();
setActivePinia(createPinia());
const storage = (() => {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, String(value));
},
removeItem: (key: string) => {
data.delete(key);
},
clear: () => {
data.clear();
},
};
})();
Object.defineProperty(window, "localStorage", {
value: storage,
configurable: true,
});
Object.defineProperty(window, "sessionStorage", {
value: storage,
configurable: true,
});
channelPostMessage.mockClear();
vi.stubGlobal(
"BroadcastChannel",
class {
onmessage: ((event: MessageEvent) => void) | null = null;
postMessage = channelPostMessage;
close() {}
},
);
});
it("shows a timeout warning one minute before automatic logout", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const { IDLE_TIMEOUT_MINUTES, TIMEOUT_WARNING_SECONDS, initSessionManager } = await import("./sessionManager");
const session = useSessionStore();
const oldTs = Date.now() - ((IDLE_TIMEOUT_MINUTES * 60 - TIMEOUT_WARNING_SECONDS) * 1000);
session.recordUserActivity(oldTs);
initSessionManager();
expect(session.timeoutWarningVisible).toBe(true);
expect(session.timeoutAt).toBe(oldTs + IDLE_TIMEOUT_MINUTES * 60 * 1000);
});
it("logs out after long inactivity when user activity resumes", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const router = (await import("../router")).default;
const { IDLE_TIMEOUT_MINUTES, LOGOUT_REASON_TIMEOUT, markUserActive } = await import("./sessionManager");
const session = useSessionStore();
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
session.recordUserActivity(oldTs);
markUserActive(Date.now());
expect(window.sessionStorage.getItem("ctms_logout_reason")).toBe(LOGOUT_REASON_TIMEOUT);
expect(router.replace).toHaveBeenCalledWith("/login");
});
it("does not force desktop logout after local inactivity", async () => {
runtimeMocks.isTauriRuntime.mockReturnValue(true);
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const router = (await import("../router")).default;
const { IDLE_TIMEOUT_MINUTES, markUserActive } = await import("./sessionManager");
const session = useSessionStore();
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
session.recordUserActivity(oldTs);
markUserActive(Date.now());
expect(window.sessionStorage.getItem("ctms_logout_reason")).toBeNull();
expect(router.replace).not.toHaveBeenCalled();
expect(session.timeoutWarningVisible).toBe(false);
});
it("clears the timeout warning when user activity resumes", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const { markUserActive } = await import("./sessionManager");
const session = useSessionStore();
session.showTimeoutWarning(Date.now() + 60_000);
markUserActive(Date.now());
expect(session.timeoutWarningVisible).toBe(false);
expect(session.timeoutAt).toBe(0);
});
it("does not persist refreshed tokens through the storage broadcast fallback", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { extendToken } = await import("../api/authClient");
vi.mocked(extendToken).mockResolvedValue({
data: {
accessToken: "new-token",
expiresAt: "2026-03-10T02:00:00.000Z",
},
} as any);
const { setToken } = await import("../utils/auth");
await setToken("old-token");
const { extendAccessToken } = await import("./sessionManager");
const result = await extendAccessToken("response-401");
expect(result).toEqual({ token: "new-token", authFailed: false });
expect(channelPostMessage).toHaveBeenCalledWith({ type: "TOKEN_UPDATED", token: "new-token" });
expect(window.localStorage.getItem("ctms_auth_broadcast")).toBeNull();
});
it("extends desktop tokens even when there has been no recent local activity", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { setToken } = await import("../utils/auth");
await setToken(createJwt(Date.now() + 30_000));
runtimeMocks.isTauriRuntime.mockReturnValue(true);
const { extendToken } = await import("../api/authClient");
vi.mocked(extendToken).mockResolvedValue({
data: {
accessToken: createJwt(Date.now() + 3_600_000),
expiresAt: "2026-03-10T02:00:00.000Z",
},
} as any);
const { useSessionStore } = await import("../store/session");
const { IDLE_TIMEOUT_MINUTES, startTokenKeepAlive } = await import("./sessionManager");
const session = useSessionStore();
session.recordUserActivity(Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000));
startTokenKeepAlive();
await vi.advanceTimersByTimeAsync(10_000);
expect(extendToken).toHaveBeenCalledTimes(1);
});
});