224 lines
7.7 KiB
TypeScript
224 lines
7.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig";
|
|
import {
|
|
clearSessionToken,
|
|
getSessionToken,
|
|
initializeSecureSessionStorage,
|
|
LEGACY_TOKEN_KEY,
|
|
resetSecureSessionStorageForTests,
|
|
setSessionToken,
|
|
} from "./secureSessionStorage";
|
|
|
|
const invokeMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: invokeMock,
|
|
}));
|
|
|
|
const SERVER_ORIGIN = "https://ctms.example.com/";
|
|
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
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`;
|
|
|
|
const createStorage = (): Storage => {
|
|
const data = new Map<string, string>();
|
|
return {
|
|
get length() {
|
|
return data.size;
|
|
},
|
|
clear: () => data.clear(),
|
|
getItem: (key) => data.get(key) ?? null,
|
|
key: (index) => Array.from(data.keys())[index] ?? null,
|
|
removeItem: (key) => data.delete(key),
|
|
setItem: (key, value) => {
|
|
data.set(key, String(value));
|
|
},
|
|
};
|
|
};
|
|
|
|
describe("secure session storage", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
|
|
resetSecureSessionStorageForTests();
|
|
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
|
|
localStorage.clear();
|
|
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
|
|
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
|
|
invokeMock.mockReset();
|
|
invokeMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
vi.useRealTimers();
|
|
resetSecureSessionStorageForTests();
|
|
localStorage.clear();
|
|
Reflect.deleteProperty(window, "isTauri");
|
|
});
|
|
|
|
it("stores desktop tokens as a 30 day secure session record", async () => {
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
|
|
await setSessionToken(token);
|
|
|
|
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
|
|
serverOrigin: SERVER_ORIGIN,
|
|
token: expect.any(String),
|
|
});
|
|
const stored = JSON.parse(invokeMock.mock.calls[0][1].token);
|
|
expect(stored).toMatchObject({ version: 1, token });
|
|
expect(stored.expiresAt - stored.storedAt).toBe(DESKTOP_SESSION_MAX_AGE_MS);
|
|
});
|
|
|
|
it("restores a valid desktop secure session record on startup", async () => {
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
invokeMock.mockImplementation(async (command: string) => {
|
|
if (command === "credential_get") {
|
|
return JSON.stringify({
|
|
version: 1,
|
|
token,
|
|
storedAt: Date.now(),
|
|
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
|
|
});
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
await initializeSecureSessionStorage();
|
|
|
|
expect(getSessionToken()).toBe(token);
|
|
expect(invokeMock).toHaveBeenCalledWith("credential_get", { serverOrigin: SERVER_ORIGIN });
|
|
});
|
|
|
|
it("migrates legacy browser tokens into the desktop credential store", async () => {
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
localStorage.setItem(LEGACY_TOKEN_KEY, token);
|
|
|
|
await initializeSecureSessionStorage();
|
|
|
|
expect(localStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull();
|
|
expect(getSessionToken()).toBe(token);
|
|
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
|
|
serverOrigin: SERVER_ORIGIN,
|
|
token: expect.any(String),
|
|
});
|
|
const stored = JSON.parse(invokeMock.mock.calls[0][1].token);
|
|
expect(stored).toMatchObject({ version: 1, token });
|
|
});
|
|
|
|
it("deletes an expired desktop secure session record on startup", async () => {
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
invokeMock.mockImplementation(async (command: string) => {
|
|
if (command === "credential_get") {
|
|
return JSON.stringify({
|
|
version: 1,
|
|
token,
|
|
storedAt: Date.now() - DESKTOP_SESSION_MAX_AGE_MS - 1_000,
|
|
expiresAt: Date.now() - 1_000,
|
|
});
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
await initializeSecureSessionStorage();
|
|
|
|
expect(getSessionToken()).toBeNull();
|
|
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN });
|
|
});
|
|
|
|
it("enforces the local 30 day desktop session ceiling even when the token expires later", async () => {
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS * 2);
|
|
invokeMock.mockImplementation(async (command: string) => {
|
|
if (command === "credential_get") {
|
|
return JSON.stringify({
|
|
version: 1,
|
|
token,
|
|
storedAt: Date.now() - DESKTOP_SESSION_MAX_AGE_MS - 1_000,
|
|
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
|
|
});
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
await initializeSecureSessionStorage();
|
|
|
|
expect(getSessionToken()).toBeNull();
|
|
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN });
|
|
});
|
|
|
|
it("does not read credentials before a desktop server URL is configured", async () => {
|
|
localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
|
|
|
|
await initializeSecureSessionStorage();
|
|
|
|
expect(getSessionToken()).toBeNull();
|
|
expect(invokeMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores credentials for the build default when no desktop server override is stored", async () => {
|
|
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
|
|
localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
invokeMock.mockResolvedValue(JSON.stringify({
|
|
version: 1,
|
|
token,
|
|
storedAt: Date.now(),
|
|
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
|
|
}));
|
|
|
|
await initializeSecureSessionStorage();
|
|
|
|
expect(getSessionToken()).toBe(token);
|
|
expect(invokeMock).toHaveBeenCalledExactlyOnceWith("credential_get", {
|
|
serverOrigin: "https://default.ctms.example.com/",
|
|
});
|
|
});
|
|
|
|
it("clears the previous server credential after a desktop server switch", async () => {
|
|
const previousServerOrigin = "https://old.ctms.example.com/";
|
|
const nextServerOrigin = "https://new.ctms.example.com/";
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
localStorage.setItem(DESKTOP_SERVER_URL_KEY, previousServerOrigin);
|
|
invokeMock.mockImplementation(async (command: string) => {
|
|
if (command === "credential_get") {
|
|
return JSON.stringify({
|
|
version: 1,
|
|
token,
|
|
storedAt: Date.now(),
|
|
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
|
|
});
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
await initializeSecureSessionStorage();
|
|
localStorage.setItem(DESKTOP_SERVER_URL_KEY, nextServerOrigin);
|
|
await clearSessionToken();
|
|
|
|
expect(getSessionToken()).toBeNull();
|
|
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: previousServerOrigin });
|
|
expect(invokeMock).not.toHaveBeenCalledWith("credential_delete", { serverOrigin: nextServerOrigin });
|
|
});
|
|
|
|
it("rewrites a legacy raw desktop token into a secure session record", async () => {
|
|
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
invokeMock.mockImplementation(async (command: string) => {
|
|
if (command === "credential_get") return token;
|
|
return undefined;
|
|
});
|
|
|
|
await initializeSecureSessionStorage();
|
|
|
|
expect(getSessionToken()).toBe(token);
|
|
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
|
|
serverOrigin: SERVER_ORIGIN,
|
|
token: expect.stringContaining(token),
|
|
});
|
|
});
|
|
});
|