feat(desktop): 支持桌面端三十天免登录

This commit is contained in:
Cheng Zhou
2026-07-02 09:06:24 +08:00
parent 8c8327df92
commit b8c5c4123a
9 changed files with 334 additions and 19 deletions
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig";
import {
getSessionToken,
initializeSecureSessionStorage,
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.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, "__TAURI_INTERNALS__", { value: {}, configurable: true });
invokeMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
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("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("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),
});
});
});