178 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|