import { post } from "@toruslabs/http-helpers"; import { decryptData, hexToBytes } from "@toruslabs/metadata-helpers"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { AuthSessionManager } from "../../src/auth/AuthSessionManager"; import { MemoryStorage } from "../../src/auth/storage/MemoryStorage"; import type { AuthTokens, RefreshResponse } from "../../src/auth/types"; import { STORAGE_KEYS } from "../../src/auth/types"; vi.mock("@toruslabs/http-helpers", () => ({ post: vi.fn(), })); vi.mock("@toruslabs/metadata-helpers", async (importOriginal) => { const actualModule = (await importOriginal()) as typeof import("@toruslabs/metadata-helpers"); return { add0x: actualModule.add0x, remove0x: actualModule.remove0x, hexToBytes: actualModule.hexToBytes, isHexString: actualModule.isHexString, assert: actualModule.assert, decryptData: vi.fn(), }; }); const mockPost = vi.mocked(post); const mockDecryptData = vi.mocked(decryptData); const mockHexToBytes = vi.mocked(hexToBytes); const TEST_TOKENS: AuthTokens = { session_id: "0x0000000000123456789012345678901234567890123456789012345678901234", access_token: "test-access-token", refresh_token: "test-refresh-token", id_token: "test-id-token", }; const REFRESH_RESPONSE: RefreshResponse = { access_token: "new-access-token", refresh_token: "new-refresh-token", session_data: "encrypted-session-data", }; const DECRYPTED_SESSION_DATA = { userId: "user-123", name: "Test User" }; describe("AuthSessionManager", () => { let session: AuthSessionManager; let memStorage: MemoryStorage; beforeEach(() => { vi.clearAllMocks(); memStorage = new MemoryStorage(); session = new AuthSessionManager({ storage: { sessionId: memStorage, accessToken: memStorage, refreshToken: memStorage, idToken: memStorage, }, }); }); // ============ Storage & State ============ describe("setTokens", () => { test("should store all 4 items via storage adapters", async () => { await session.setTokens(TEST_TOKENS); expect(await memStorage.get(`w3a:${STORAGE_KEYS.SESSION_ID}`)).toBe(TEST_TOKENS.session_id); expect(await memStorage.get(`w3a:${STORAGE_KEYS.ACCESS_TOKEN}`)).toBe("test-access-token"); expect(await memStorage.get(`w3a:${STORAGE_KEYS.REFRESH_TOKEN}`)).toBe("test-refresh-token"); expect(await memStorage.get(`w3a:${STORAGE_KEYS.ID_TOKEN}`)).toBe("test-id-token"); }); }); describe("getters", () => { test("should return stored values after setTokens", async () => { await session.setTokens(TEST_TOKENS); expect(await session.getAccessToken()).toBe("test-access-token"); expect(await session.getRefreshToken()).toBe("test-refresh-token"); expect(await session.getIdToken()).toBe("test-id-token"); expect(await session.getSessionId()).toBe(TEST_TOKENS.session_id); }); test("should return null before tokens are set", async () => { expect(await session.getAccessToken()).toBeNull(); expect(await session.getRefreshToken()).toBeNull(); expect(await session.getIdToken()).toBeNull(); expect(await session.getSessionId()).toBeNull(); }); }); describe("individual setters", () => { test("setAccessToken updates access token", async () => { await session.setAccessToken("new-at"); expect(await session.getAccessToken()).toBe("new-at"); expect(await memStorage.get(`w3a:${STORAGE_KEYS.ACCESS_TOKEN}`)).toBe("new-at"); }); test("setRefreshToken updates refresh token", async () => { await session.setRefreshToken("new-rt"); expect(await session.getRefreshToken()).toBe("new-rt"); expect(await memStorage.get(`w3a:${STORAGE_KEYS.REFRESH_TOKEN}`)).toBe("new-rt"); }); }); describe("isAuthenticated", () => { test("returns false without tokens", () => { expect(session.isAuthenticated()).toBe(false); }); test("returns false after setTokens without authorize", async () => { await session.setTokens(TEST_TOKENS); expect(session.isAuthenticated()).toBe(false); }); test("returns false after clearing session data", async () => { await session.setTokens(TEST_TOKENS); await session.clearSessionData(); expect(session.isAuthenticated()).toBe(false); }); }); describe("clearSessionData", () => { test("should remove all tokens from storage and memory", async () => { await session.setTokens(TEST_TOKENS); await session.clearSessionData(); expect(await session.getAccessToken()).toBeNull(); expect(await session.getRefreshToken()).toBeNull(); expect(await session.getIdToken()).toBeNull(); expect(await session.getSessionId()).toBeNull(); expect(session.isAuthenticated()).toBe(false); }); }); describe("state snapshot", () => { test("should return unauthenticated state initially", async () => { const state = await session.getState(); expect(state.isAuthenticated).toBe(false); expect(state.sessionId).toBeNull(); }); test("should return unauthenticated state after setTokens without authorize", async () => { await session.setTokens(TEST_TOKENS); const state = await session.getState(); expect(state.isAuthenticated).toBe(false); expect(state.sessionId).toBe(TEST_TOKENS.session_id); }); test("should not include isLoading", async () => { const state = await session.getState(); expect(state).not.toHaveProperty("isLoading"); }); }); describe("accessTokenProvider", () => { test("getAccessToken should delegate to provider when set", async () => { await session.setTokens(TEST_TOKENS); session.accessTokenProvider = vi.fn().mockResolvedValue("provider-token"); const result = await session.getAccessToken(); expect(result).toBe("provider-token"); }); test("getAccessToken should return stored token when no provider", async () => { await session.setTokens(TEST_TOKENS); const result = await session.getAccessToken(); expect(result).toBe("test-access-token"); }); }); // ============ API Operations ============ describe("API operations", () => { let apiSession: AuthSessionManager; beforeEach(() => { const mem = new MemoryStorage(); apiSession = new AuthSessionManager({ apiClientConfig: { baseURL: "https://api.example.com", sessionsEndpoint: "/sessions", logoutEndpoint: "/logout", }, storage: { sessionId: mem, accessToken: mem, refreshToken: mem, idToken: mem, }, }); }); describe("authorize", () => { test("should refresh, decrypt, and return session_data", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); mockDecryptData.mockResolvedValueOnce(DECRYPTED_SESSION_DATA); const result = await apiSession.authorize(); expect(result).toEqual(DECRYPTED_SESSION_DATA); expect(mockPost).toHaveBeenCalledTimes(1); expect(mockDecryptData).toHaveBeenCalledWith(mockHexToBytes(TEST_TOKENS.session_id), "encrypted-session-data"); expect(await apiSession.getAccessToken()).toBe("new-access-token"); }); test("should read sessionId from storage for decryption", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); mockDecryptData.mockResolvedValueOnce(DECRYPTED_SESSION_DATA); await apiSession.authorize(); expect(mockDecryptData).toHaveBeenCalledWith(mockHexToBytes(TEST_TOKENS.session_id), "encrypted-session-data"); }); test("should return null if no refresh token", async () => { const result = await apiSession.authorize(); expect(result).toBeNull(); }); test("should throw if apiClientConfig is not provided", async () => { await expect(session.authorize()).rejects.toThrow("apiClientConfig is required"); }); test("should clear tokens and return null if refresh fails", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockRejectedValueOnce(new Error("network error")); const result = await apiSession.authorize(); expect(result).toBeNull(); expect(await apiSession.getRefreshToken()).toBeNull(); }); test("should clear tokens and return null if decryption fails", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); mockDecryptData.mockResolvedValueOnce({ error: "bad key" }); const result = await apiSession.authorize(); expect(result).toBeNull(); expect(await apiSession.getRefreshToken()).toBeNull(); }); test("should set isAuthenticated to true after successful authorize", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); mockDecryptData.mockResolvedValueOnce(DECRYPTED_SESSION_DATA); expect(apiSession.isAuthenticated()).toBe(false); await apiSession.authorize(); expect(apiSession.isAuthenticated()).toBe(true); }); test("should always call API (skipIfFresh=false)", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); mockDecryptData.mockResolvedValueOnce(DECRYPTED_SESSION_DATA); const result = await apiSession.authorize(); expect(result).toEqual(DECRYPTED_SESSION_DATA); expect(mockPost).toHaveBeenCalledTimes(1); }); }); describe("ensureRefresh", () => { test("should call /sessions with AT in header and RT in body", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); await apiSession.ensureRefresh(); expect(mockPost).toHaveBeenCalledWith( "https://api.example.com/sessions", { refresh_token: "test-refresh-token" }, { headers: { "Content-Type": "application/json", Authorization: "Bearer test-access-token", }, } ); }); test("should update tokens after refresh", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); await apiSession.ensureRefresh(); expect(await apiSession.getAccessToken()).toBe("new-access-token"); expect(await apiSession.getRefreshToken()).toBe("new-refresh-token"); }); test("should deduplicate concurrent refresh calls", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); const [result1, result2] = await Promise.all([apiSession.ensureRefresh(), apiSession.ensureRefresh()]); expect(mockPost).toHaveBeenCalledTimes(1); expect(result1).toEqual(result2); }); test("should throw when no access token and no refresh token", async () => { await expect(apiSession.ensureRefresh()).rejects.toThrow("No access token or refresh token available"); }); test("should work with only access token (no refresh token)", async () => { await apiSession.setAccessToken("at-only"); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); const response = await apiSession.ensureRefresh(); expect(response).toEqual(REFRESH_RESPONSE); expect(mockPost).toHaveBeenCalledWith( "https://api.example.com/sessions", {}, { headers: { "Content-Type": "application/json", Authorization: "Bearer at-only", }, } ); }); }); describe("handleUnauthorized", () => { test("should trigger refresh and return new token", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); const token = await apiSession.handleUnauthorized(); expect(token).toBe("new-access-token"); }); test("should skip API call when another tab refreshed (skipIfFresh)", async () => { await apiSession.setTokens(TEST_TOKENS); // Simulate another tab having refreshed: write a different token // directly to storage (as another tab's performRefresh would) const mem = apiSession["storage"].accessToken; await mem.set("w3a:access_token", "other-tab-token"); const token = await apiSession.handleUnauthorized(); expect(mockPost).not.toHaveBeenCalled(); expect(token).toBe("other-tab-token"); }); }); describe("logout", () => { test("should call /logout with refresh_token in body and clear tokens", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockResolvedValueOnce({}); await apiSession.logout(); expect(mockPost).toHaveBeenCalledWith( "https://api.example.com/logout", { refresh_token: "test-refresh-token" }, { headers: { "Content-Type": "application/json", Authorization: "Bearer test-access-token", }, } ); expect(await apiSession.getAccessToken()).toBeNull(); expect(await apiSession.getRefreshToken()).toBeNull(); }); test("should clear tokens even if /logout fails", async () => { await apiSession.setTokens(TEST_TOKENS); mockPost.mockRejectedValueOnce(new Error("network error")); await apiSession.logout(); expect(await apiSession.getAccessToken()).toBeNull(); }); test("should omit refresh_token from body when not available", async () => { await apiSession.setAccessToken("at-only"); mockPost.mockResolvedValueOnce({}); await apiSession.logout(); expect(mockPost).toHaveBeenCalledWith( "https://api.example.com/logout", {}, { headers: { "Content-Type": "application/json", Authorization: "Bearer at-only", }, } ); }); test("should use accessTokenProvider in logout when set", async () => { apiSession.accessTokenProvider = vi.fn().mockResolvedValue("provider-token"); await apiSession.setRefreshToken("rt-to-revoke"); mockPost.mockResolvedValueOnce({}); await apiSession.logout(); expect(mockPost).toHaveBeenCalledWith( "https://api.example.com/logout", { refresh_token: "rt-to-revoke" }, { headers: { "Content-Type": "application/json", Authorization: "Bearer provider-token", }, } ); }); }); }); });