完善桌面端回归与安全边界复审
This commit is contained in:
@@ -195,6 +195,7 @@ describe("desktop layout shell", () => {
|
||||
expect(preferences).not.toContain("copyConnectionDiagnostic");
|
||||
expect(preferences).not.toContain("服务器连通性正常");
|
||||
expect(preferences).not.toContain("服务器连接已确认");
|
||||
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
|
||||
expect(preferences).not.toContain("系统通知已开启");
|
||||
expect(preferences).not.toContain("系统通知已关闭");
|
||||
expect(preferences).not.toContain("通知诊断");
|
||||
|
||||
@@ -86,6 +86,21 @@ describe("admin project route permissions", () => {
|
||||
expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/admin/projects",');
|
||||
});
|
||||
|
||||
it("validates restored session tokens through /me before entering protected routes", () => {
|
||||
const source = readRouter();
|
||||
const restoreGuardIndex = source.indexOf("if (!auth.user && getToken())");
|
||||
const fetchMeIndex = source.indexOf("await auth.fetchMe()", restoreGuardIndex);
|
||||
const logoutIndex = source.indexOf("await auth.logout()", fetchMeIndex);
|
||||
const loginRedirectIndex = source.indexOf('next({ path: "/login" })', logoutIndex);
|
||||
const projectFallbackIndex = source.indexOf("if (token && !studyStore.currentStudy)", restoreGuardIndex);
|
||||
|
||||
expect(restoreGuardIndex).toBeGreaterThan(-1);
|
||||
expect(fetchMeIndex).toBeGreaterThan(restoreGuardIndex);
|
||||
expect(logoutIndex).toBeGreaterThan(fetchMeIndex);
|
||||
expect(loginRedirectIndex).toBeGreaterThan(logoutIndex);
|
||||
expect(fetchMeIndex).toBeLessThan(projectFallbackIndex);
|
||||
});
|
||||
|
||||
it("does not register the removed non-admin personal dashboard route", () => {
|
||||
const source = readRouter();
|
||||
const removedComponent = ["My", "Work", "bench"].join("");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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";
|
||||
@@ -91,6 +93,22 @@ describe("secure session storage", () => {
|
||||
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) => {
|
||||
@@ -111,6 +129,61 @@ describe("secure session storage", () => {
|
||||
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("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) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
const LEGACY_TOKEN_KEY = "ctms_token";
|
||||
export const LEGACY_TOKEN_KEY = "ctms_token";
|
||||
const DESKTOP_SESSION_RECORD_VERSION = 1;
|
||||
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -117,6 +117,29 @@ describe("desktop update manager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("removes URLs and credential-shaped text from update prompts", async () => {
|
||||
const tokenParam = ["to", "ken"].join("");
|
||||
checkForDesktopUpdateMock.mockResolvedValue({
|
||||
...update,
|
||||
notes: [
|
||||
"桌面端稳定化",
|
||||
`下载地址 https://downloads.example.com/ctms?${tokenParam}=secret`,
|
||||
"Authorization: Bearer secret",
|
||||
"access_token=secret",
|
||||
].join("\n"),
|
||||
});
|
||||
const { checkDesktopUpdateAndPrompt } = await import("./desktopUpdateManager");
|
||||
|
||||
await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
|
||||
|
||||
const promptText = String(confirmMock.mock.calls[0][0]);
|
||||
expect(promptText).toContain("桌面端稳定化");
|
||||
expect(promptText).not.toContain("https://");
|
||||
expect(promptText).not.toContain(`${tokenParam}=`);
|
||||
expect(promptText).not.toContain("Authorization");
|
||||
expect(promptText).not.toContain("Bearer");
|
||||
});
|
||||
|
||||
it("does not interrupt timed update checks when checking fails", async () => {
|
||||
checkForDesktopUpdateMock.mockRejectedValueOnce(new Error("feed unavailable"));
|
||||
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
|
||||
|
||||
@@ -96,10 +96,23 @@ const postponeVersion = (version: string) => {
|
||||
|
||||
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now();
|
||||
|
||||
const unsafeReleaseNotePattern = /\b(?:https?:\/\/|token=|access_token=|authorization|bearer)\b/i;
|
||||
|
||||
const sanitizeReleaseNotes = (notes: string): string =>
|
||||
notes
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !unsafeReleaseNotePattern.test(line))
|
||||
.slice(0, 8)
|
||||
.join("\n")
|
||||
.slice(0, 800)
|
||||
.trim();
|
||||
|
||||
const releaseNotes = (update: DesktopUpdateInfo): string => {
|
||||
const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`];
|
||||
if (update.notes?.trim()) {
|
||||
lines.push("", "发布说明:", update.notes.trim());
|
||||
const notes = update.notes ? sanitizeReleaseNotes(update.notes) : "";
|
||||
if (notes) {
|
||||
lines.push("", "发布说明:", notes);
|
||||
}
|
||||
lines.push("", "确认后将下载、验签、安装并重启应用。");
|
||||
return lines.join("\n");
|
||||
|
||||
@@ -7,6 +7,8 @@ vi.mock("../router", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const channelPostMessage = vi.fn();
|
||||
|
||||
vi.mock("../api/authClient", () => ({
|
||||
extendToken: vi.fn(),
|
||||
}));
|
||||
@@ -39,14 +41,15 @@ describe("session manager idle logout", () => {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "BroadcastChannel", {
|
||||
value: class {
|
||||
channelPostMessage.mockClear();
|
||||
vi.stubGlobal(
|
||||
"BroadcastChannel",
|
||||
class {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
postMessage() {}
|
||||
postMessage = channelPostMessage;
|
||||
close() {}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a timeout warning one minute before automatic logout", async () => {
|
||||
@@ -92,4 +95,24 @@ describe("session manager idle logout", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const broadcast = (message: BroadcastMessage) => {
|
||||
if (channel) {
|
||||
channel.postMessage(message);
|
||||
}
|
||||
if (message.type === "TOKEN_UPDATED") return;
|
||||
try {
|
||||
localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() }));
|
||||
} catch {
|
||||
|
||||
@@ -426,7 +426,7 @@ const scheduleConnectionPending = (message: string) => {
|
||||
};
|
||||
|
||||
const clearSessionForServerChange = async () => {
|
||||
await auth.logout();
|
||||
await auth.logout({ rememberCurrentStudy: false });
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user