import { describe, it, expect, vi, beforeEach } from "vitest"; import { buildRedirectUrl, buildNewSessionUrl, buildChatSessionUrl, syncSessionCookie, syncCookieAndGetRedirectUrl, syncCookieAndGetNewSessionUrl, syncCookieAndGetChatUrl, persistTicketCookie, } from "./sessionUrl"; const { mockSettings, mockSession, mockLogger } = vi.hoisted(() => ({ mockSettings: { get: vi.fn(), set: vi.fn() }, mockSession: { setCookie: vi.fn(), getCookie: vi.fn() }, mockLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); vi.stubGlobal("window", { electronAPI: { settings: mockSettings, session: mockSession }, }); const mockGetCurrentAuth = vi.fn(); vi.mock("../core/auth", () => ({ getCurrentAuth: (...args: unknown[]) => mockGetCurrentAuth(...args), })); vi.mock("./logService", () => ({ logger: mockLogger, })); beforeEach(() => { vi.clearAllMocks(); mockSession.setCookie.mockResolvedValue({ success: true }); mockSession.getCookie.mockResolvedValue({ success: true, found: false }); }); describe("buildRedirectUrl", () => { it("strips trailing slashes and builds correct URL", () => { expect(buildRedirectUrl("https://example.com///", "user1")).toBe( "https://example.com/api/sandbox/config/redirect/user1?hideMenu=true", ); expect(buildRedirectUrl("https://example.com/", "user1")).toBe( "https://example.com/api/sandbox/config/redirect/user1?hideMenu=true", ); expect(buildRedirectUrl("https://example.com", "user1")).toBe( "https://example.com/api/sandbox/config/redirect/user1?hideMenu=true", ); }); it("works with numeric configId", () => { expect(buildRedirectUrl("https://example.com", 42)).toBe( "https://example.com/api/sandbox/config/redirect/42?hideMenu=true", ); }); }); describe("buildNewSessionUrl", () => { it("builds correct new-session URL", () => { expect(buildNewSessionUrl("https://example.com", 42)).toBe( "https://example.com/api/sandbox/config/redirect/new/42?hideMenu=true", ); }); it("strips trailing slashes", () => { expect(buildNewSessionUrl("https://example.com///", 42)).toBe( "https://example.com/api/sandbox/config/redirect/new/42?hideMenu=true", ); }); }); describe("buildChatSessionUrl", () => { it("builds correct URL with session id", () => { expect(buildChatSessionUrl("https://example.com", "sess-abc-123")).toBe( "https://example.com/api/sandbox/config/redirect/chat/sess-abc-123?hideMenu=true", ); }); it("strips trailing slashes", () => { expect(buildChatSessionUrl("https://example.com///", "s1")).toBe( "https://example.com/api/sandbox/config/redirect/chat/s1?hideMenu=true", ); }); }); describe("syncSessionCookie", () => { it("不设 domain 和 secure,由主进程根据 URL scheme 判断", async () => { await syncSessionCookie("https://app.example.com:8080/path", "tok123"); const payload = mockSession.setCookie.mock.calls[0][0]; expect(payload).toEqual({ url: "https://app.example.com:8080/path", name: "ticket", value: "tok123", httpOnly: true, }); expect(payload).not.toHaveProperty("domain"); expect(payload).not.toHaveProperty("secure"); }); it("invalid URL 也不设 domain 和 secure", async () => { await syncSessionCookie("not-a-valid-url", "tok123"); const payload = mockSession.setCookie.mock.calls[0][0]; expect(payload).toEqual({ url: "not-a-valid-url", name: "ticket", value: "tok123", httpOnly: true, }); expect(payload).not.toHaveProperty("domain"); expect(payload).not.toHaveProperty("secure"); }); it("host-only cookie — 不设 domain,与 webview Set-Cookie 行为一致", async () => { await syncSessionCookie("https://example.com", "tok"); const payload = mockSession.setCookie.mock.calls[0][0]; expect(payload).not.toHaveProperty("domain"); }); it("IPv4 host 也不设 domain", async () => { await syncSessionCookie("http://127.0.0.1:8080", "tok-ip"); const payload = mockSession.setCookie.mock.calls[0][0]; expect(payload).toEqual({ url: "http://127.0.0.1:8080", name: "ticket", value: "tok-ip", httpOnly: true, }); expect(payload).not.toHaveProperty("domain"); }); it("localhost 也不设 domain", async () => { await syncSessionCookie("http://localhost:3000", "tok-local"); const payload = mockSession.setCookie.mock.calls[0][0]; expect(payload).toEqual({ url: "http://localhost:3000", name: "ticket", value: "tok-local", httpOnly: true, }); expect(payload).not.toHaveProperty("domain"); }); }); describe("syncCookieAndGetRedirectUrl", () => { it("returns null when not logged in", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: false, userInfo: null, }); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBeNull(); expect(mockSession.setCookie).not.toHaveBeenCalled(); }); it("returns null when domain is missing", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 1, username: "u" }, }); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBeNull(); expect(mockSession.setCookie).not.toHaveBeenCalled(); }); it("returns null when userId is missing", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { currentDomain: "https://example.com", username: "u" }, }); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBeNull(); expect(mockSession.setCookie).not.toHaveBeenCalled(); }); it("returns redirect URL and syncs cookie when all present", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("my-token"); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockSession.setCookie).toHaveBeenCalledWith( expect.objectContaining({ url: "https://example.com", name: "ticket", value: "my-token", }), ); // host-only cookie,不设 domain const payload = mockSession.setCookie.mock.calls[0][0]; expect(payload).not.toHaveProperty("domain"); }); it("clears local auth token after syncing to webview cookie", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("my-token"); await syncCookieAndGetRedirectUrl(); expect(mockSession.setCookie).toHaveBeenCalled(); expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null); }); it("keeps local auth token when cookie sync fails", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("my-token"); mockSession.setCookie.mockResolvedValue({ success: false, error: "cookie write failed", }); await expect(syncCookieAndGetRedirectUrl()).rejects.toThrow( "cookie write failed", ); expect(mockSettings.set).not.toHaveBeenCalledWith("auth.token", null); }); it("returns URL without syncing cookie when token is null", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue(null); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockSession.setCookie).not.toHaveBeenCalled(); }); it("overwrites cookie when domain cache token exists (regardless of existing cookie)", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockImplementation((key: string) => { if (key === "auth.token") return Promise.resolve(null); if (key === "auth.tokens.example.com") return Promise.resolve("my-token"); return Promise.resolve(null); }); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); // 有 token 时无条件覆盖,不管现有 cookie 状态 expect(mockSession.setCookie).toHaveBeenCalledWith( expect.objectContaining({ url: "https://example.com", name: "ticket", value: "my-token", }), ); }); it("overwrites existing ticket when one-shot auth token is present", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("fresh-token"); mockSession.getCookie.mockResolvedValue({ success: true, found: true }); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockSession.setCookie).toHaveBeenCalled(); expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null); }); it("prints diagnostic logs regardless of NODE_ENV", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("my-token"); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockLogger.debug).toHaveBeenCalledWith( "[SessionUrl] Pre-session state", "SessionUrl", expect.objectContaining({ domain: "https://example.com", hasToken: true, }), ); expect(mockLogger.debug).toHaveBeenCalledWith( "[SessionUrl] ticket cookie synced successfully", "SessionUrl", expect.objectContaining({ domain: "https://example.com" }), ); }); }); describe("syncCookieAndGetNewSessionUrl", () => { it("returns null when not logged in", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: false, userInfo: null, }); const result = await syncCookieAndGetNewSessionUrl(); expect(result).toBeNull(); }); it("returns new-session URL and syncs cookie", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("my-token"); const result = await syncCookieAndGetNewSessionUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/new/7?hideMenu=true", ); expect(mockSession.setCookie).toHaveBeenCalled(); }); }); describe("syncCookieAndGetChatUrl", () => { it("returns null when not logged in", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: false, userInfo: null, }); const result = await syncCookieAndGetChatUrl("sess-1"); expect(result).toBeNull(); }); it("returns null when domain is missing", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 1, username: "u" }, }); const result = await syncCookieAndGetChatUrl("sess-1"); expect(result).toBeNull(); }); it("returns chat URL and syncs cookie", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("my-token"); const result = await syncCookieAndGetChatUrl("sess-abc"); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/chat/sess-abc?hideMenu=true", ); expect(mockSession.setCookie).toHaveBeenCalled(); }); it("returns chat URL even when user id is missing", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue("my-token"); const result = await syncCookieAndGetChatUrl("sess-no-id"); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/chat/sess-no-id?hideMenu=true", ); expect(mockSession.setCookie).toHaveBeenCalled(); }); it("returns chat URL without cookie when token is null", async () => { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: "https://example.com", username: "u" }, }); mockSettings.get.mockResolvedValue(null); const result = await syncCookieAndGetChatUrl("sess-abc"); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/chat/sess-abc?hideMenu=true", ); expect(mockSession.setCookie).not.toHaveBeenCalled(); }); }); // --- JWT expiry & token cache clearing --- /** Helper: build a minimal JWT-like string with a given exp (epoch seconds). */ function makeJwt(exp: number): string { const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })); const payload = btoa(JSON.stringify({ exp })); return `${header}.${payload}.sig`; } function setupAuthWithDomain(domain = "https://example.com") { mockGetCurrentAuth.mockResolvedValue({ isLoggedIn: true, userInfo: { id: 7, currentDomain: domain, username: "u" }, }); } describe("syncCookieAndBuildUrl — JWT expiry guard", () => { it("skips cookie sync when one-shot token is expired, clears AUTH_TOKEN only", async () => { setupAuthWithDomain(); const expiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 60); // expired 60s ago mockSettings.get.mockResolvedValue(expiredJwt); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockSession.setCookie).not.toHaveBeenCalled(); // Only AUTH_TOKEN cleared, not domain cache expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null); expect(mockSettings.set).not.toHaveBeenCalledWith( "auth.tokens.example.com", null, ); }); it("skips cookie sync when domain cache token is expired, clears domain key only", async () => { setupAuthWithDomain(); const expiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 60); mockSettings.get.mockImplementation((key: string) => { if (key === "auth.token") return Promise.resolve(null); if (key === "auth.tokens.example.com") return Promise.resolve(expiredJwt); return Promise.resolve(null); }); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockSession.setCookie).not.toHaveBeenCalled(); // Only domain key cleared, not global AUTH_TOKEN expect(mockSettings.set).toHaveBeenCalledWith( "auth.tokens.example.com", null, ); expect(mockSettings.set).not.toHaveBeenCalledWith("auth.token", null); }); it("proceeds with cookie sync when token is not expired", async () => { setupAuthWithDomain(); const validJwt = makeJwt(Math.floor(Date.now() / 1000) + 3600); // expires in 1h mockSettings.get.mockResolvedValue(validJwt); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockSession.setCookie).toHaveBeenCalledWith( expect.objectContaining({ name: "ticket", value: validJwt }), ); }); it("proceeds with cookie sync for non-JWT token (no exp field)", async () => { setupAuthWithDomain(); mockSettings.get.mockResolvedValue("opaque-token-not-jwt"); const result = await syncCookieAndGetRedirectUrl(); expect(result).toBe( "https://example.com/api/sandbox/config/redirect/7?hideMenu=true", ); expect(mockSession.setCookie).toHaveBeenCalledWith( expect.objectContaining({ value: "opaque-token-not-jwt" }), ); }); it("proceeds with cookie sync for malformed JWT", async () => { setupAuthWithDomain(); mockSettings.get.mockResolvedValue("not-even..a..jwt"); const result = await syncCookieAndGetRedirectUrl(); expect(mockSession.setCookie).toHaveBeenCalled(); }); it("treats token as valid if expired within clock-skew buffer (30s)", async () => { setupAuthWithDomain(); // Expired only 10 seconds ago — within the 30s grace period const barelyExpiredJwt = makeJwt(Math.floor(Date.now() / 1000) - 10); mockSettings.get.mockResolvedValue(barelyExpiredJwt); const result = await syncCookieAndGetRedirectUrl(); // Should still sync — not treated as expired expect(mockSession.setCookie).toHaveBeenCalledWith( expect.objectContaining({ value: barelyExpiredJwt }), ); }); }); describe("persistTicketCookie", () => { it("clears both AUTH_TOKEN and domain token cache after webview login", async () => { mockSession.getCookie.mockResolvedValue({ found: true }); mockSession.flushStore = vi.fn().mockResolvedValue(undefined); await persistTicketCookie("https://example.com"); expect(mockSettings.set).toHaveBeenCalledWith("auth.token", null); expect(mockSettings.set).toHaveBeenCalledWith( "auth.tokens.example.com", null, ); }); it("skips clearing when no ticket cookie is present", async () => { mockSession.getCookie.mockResolvedValue({ found: false }); await persistTicketCookie("https://example.com"); expect(mockSettings.set).not.toHaveBeenCalled(); }); it("does not throw on failure, logs warning instead", async () => { mockSession.getCookie.mockRejectedValue(new Error("cookie read failed")); await expect( persistTicketCookie("https://example.com"), ).resolves.toBeUndefined(); expect(mockLogger.warn).toHaveBeenCalledWith( "[SessionUrl] persistTicketCookie failed", "SessionUrl", expect.objectContaining({ domain: "https://example.com" }), ); }); });