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