import { get as httpGet, patch as httpPatch, post as httpPost, put as httpPut, remove as httpDelete } from "@toruslabs/http-helpers"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { AuthSessionManager } from "../../src/auth/AuthSessionManager"; import { AuthError } from "../../src/auth/errors"; import { HttpClient } from "../../src/auth/HttpClient"; import { MemoryStorage } from "../../src/auth/storage/MemoryStorage"; import type { AuthTokens, IHttpSessionAuthProvider, RefreshResponse } from "../../src/auth/types"; vi.mock("@toruslabs/http-helpers"); const mockGet = vi.mocked(httpGet); const mockPost = vi.mocked(httpPost); const mockPut = vi.mocked(httpPut); const mockPatch = vi.mocked(httpPatch); const mockDelete = vi.mocked(httpDelete); const TEST_TOKENS: AuthTokens = { sessionId: "0x0000000000123456789012345678901234567890123456789012345678901234", accessToken: "test-access-token", refreshToken: "test-refresh-token", idToken: "test-id-token", }; const REFRESH_RESPONSE: RefreshResponse = { access_token: "refreshed-access-token", refresh_token: "refreshed-refresh-token", session_data: "encrypted-data", }; function make401Response(): Response { return new Response(null, { status: 401, statusText: "Unauthorized" }); } function make500Response(): Response { return new Response(null, { status: 500, statusText: "Internal Server Error" }); } describe("HttpClient", () => { let session: AuthSessionManager; let http: HttpClient; beforeEach(async () => { vi.clearAllMocks(); const mem = new MemoryStorage(); session = new AuthSessionManager({ apiClientConfig: { baseURL: "https://api.example.com", sessionsEndpoint: "/sessions", }, storage: { sessionId: mem, accessToken: mem, refreshToken: mem, idToken: mem, }, }); await session.setTokens(TEST_TOKENS); http = new HttpClient(session); }); describe("authenticated requests", () => { test("GET should attach Authorization header", async () => { mockGet.mockResolvedValueOnce({ data: "ok" }); await http.get("https://api.example.com/users", { authenticated: true }); expect(mockGet).toHaveBeenCalledWith("https://api.example.com/users", { headers: { Authorization: "Bearer test-access-token" } }, {}); }); test("POST should attach Authorization header", async () => { mockPost.mockResolvedValueOnce({ id: 1 }); await http.post("https://api.example.com/users", { name: "John" }, { authenticated: true }); expect(mockPost).toHaveBeenCalledWith( "https://api.example.com/users", { name: "John" }, { headers: { Authorization: "Bearer test-access-token" } }, {} ); }); test("PUT should attach Authorization header", async () => { mockPut.mockResolvedValueOnce({ updated: true }); await http.put("https://api.example.com/users/1", { name: "Jane" }, { authenticated: true }); expect(mockPut).toHaveBeenCalledWith( "https://api.example.com/users/1", { name: "Jane" }, { headers: { Authorization: "Bearer test-access-token" } }, {} ); }); test("PATCH should attach Authorization header", async () => { mockPatch.mockResolvedValueOnce({ patched: true }); await http.patch("https://api.example.com/users/1", { name: "Jane" }, { authenticated: true }); expect(mockPatch).toHaveBeenCalledWith( "https://api.example.com/users/1", { name: "Jane" }, { headers: { Authorization: "Bearer test-access-token" } }, {} ); }); test("DELETE should attach Authorization header", async () => { mockDelete.mockResolvedValueOnce({ deleted: true }); await http.delete("https://api.example.com/users/1", {}, { authenticated: true }); expect(mockDelete).toHaveBeenCalledWith("https://api.example.com/users/1", {}, { headers: { Authorization: "Bearer test-access-token" } }, {}); }); test("should not attach Authorization header by default", async () => { mockGet.mockResolvedValueOnce({ data: "ok" }); await http.get("https://api.example.com/users"); expect(mockGet).toHaveBeenCalledWith("https://api.example.com/users", { headers: {} }, {}); }); }); describe("public (unauthenticated) requests", () => { test("GET should not attach Authorization header when authenticated: false", async () => { mockGet.mockResolvedValueOnce({ status: "ok" }); await http.get("https://api.example.com/health", { authenticated: false }); expect(mockGet).toHaveBeenCalledWith("https://api.example.com/health", { headers: {} }, {}); }); test("POST should not attach Authorization header when authenticated: false", async () => { mockPost.mockResolvedValueOnce({ ok: true }); await http.post("https://api.example.com/public", { foo: "bar" }, { authenticated: false }); expect(mockPost).toHaveBeenCalledWith("https://api.example.com/public", { foo: "bar" }, { headers: {} }, {}); }); }); describe("401 retry", () => { test("should refresh and retry on 401", async () => { mockGet.mockRejectedValueOnce(make401Response()).mockResolvedValueOnce({ data: "after-refresh" }); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); const result = await http.get("https://api.example.com/protected", { authenticated: true }); expect(result).toEqual({ data: "after-refresh" }); expect(mockGet).toHaveBeenCalledTimes(2); const secondCallHeaders = mockGet.mock.calls[1][1]; expect(secondCallHeaders).toEqual({ headers: { Authorization: "Bearer refreshed-access-token" }, }); }); test("should not retry on non-401 errors", async () => { mockGet.mockRejectedValueOnce(make500Response()); await expect(http.get("https://api.example.com/protected", { authenticated: true })).rejects.toBeInstanceOf(Response); expect(mockGet).toHaveBeenCalledTimes(1); }); test("should not retry when authenticated: false", async () => { mockGet.mockRejectedValueOnce(make401Response()); await expect(http.get("https://api.example.com/public", { authenticated: false })).rejects.toBeInstanceOf(Response); expect(mockGet).toHaveBeenCalledTimes(1); }); test("should propagate error if retry also fails", async () => { const retryError = make500Response(); mockGet.mockRejectedValueOnce(make401Response()).mockRejectedValueOnce(retryError); mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); await expect(http.get("https://api.example.com/protected", { authenticated: true })).rejects.toBe(retryError); expect(mockGet).toHaveBeenCalledTimes(2); }); test("should throw SESSION_EXPIRED AuthError when refresh fails", async () => { mockGet.mockRejectedValueOnce(make401Response()); mockPost.mockRejectedValueOnce(new Error("refresh token invalid")); const error = await http.get("https://api.example.com/protected", { authenticated: true }).catch((e) => e); expect(error).toBeInstanceOf(AuthError); expect((error as AuthError).code).toBe("SESSION_EXPIRED"); expect((error as AuthError).message).toBe("Session expired, please re-authenticate"); }); }); describe("request queuing during refresh", () => { test("concurrent 401s should trigger only one refresh", async () => { // Both GET requests will 401, then succeed on retry mockGet .mockRejectedValueOnce(make401Response()) .mockRejectedValueOnce(make401Response()) .mockResolvedValueOnce({ data: "a" }) .mockResolvedValueOnce({ data: "b" }); // Single refresh call for both mockPost.mockResolvedValueOnce(REFRESH_RESPONSE); const [resultA, resultB] = await Promise.all([ http.get("https://api.example.com/a", { authenticated: true }), http.get("https://api.example.com/b", { authenticated: true }), ]); expect(resultA).toEqual({ data: "a" }); expect(resultB).toEqual({ data: "b" }); // Only one refresh POST to /sessions expect(mockPost).toHaveBeenCalledTimes(1); }); test("new requests during refresh should wait and use fresh token", async () => { let resolveRefresh!: (value: RefreshResponse) => void; const refreshPromise = new Promise((resolve) => { resolveRefresh = resolve; }); // First request: 401 then retry succeeds mockGet .mockRejectedValueOnce(make401Response()) .mockResolvedValueOnce({ data: "first-retry" }) .mockResolvedValueOnce({ data: "second-after-wait" }); // Refresh will be held until we resolve it mockPost.mockReturnValueOnce(refreshPromise as never); // Fire the first request (triggers refresh) const firstRequest = http.get("https://api.example.com/first", { authenticated: true }); // Give the first request time to hit 401 and start refresh await vi.waitFor(() => { expect(mockPost).toHaveBeenCalledTimes(1); }); // Fire a second request while refresh is in-flight -- it should wait const secondRequest = http.get("https://api.example.com/second", { authenticated: true }); // Resolve the refresh resolveRefresh(REFRESH_RESPONSE); const [first, second] = await Promise.all([firstRequest, secondRequest]); expect(first).toEqual({ data: "first-retry" }); expect(second).toEqual({ data: "second-after-wait" }); // The second request should have used the refreshed token, NOT the stale one // Calls: [0] first with stale, [1] first retry with fresh, [2] second with fresh const secondRequestHeaders = mockGet.mock.calls[2][1]; expect(secondRequestHeaders).toEqual({ headers: { Authorization: "Bearer refreshed-access-token" }, }); // Only one refresh call total expect(mockPost).toHaveBeenCalledTimes(1); }); test("public requests should not wait for refresh gate", async () => { let resolveRefresh!: (value: RefreshResponse) => void; const refreshPromise = new Promise((resolve) => { resolveRefresh = resolve; }); mockGet.mockRejectedValueOnce(make401Response()).mockResolvedValueOnce({ data: "public" }).mockResolvedValueOnce({ data: "retry" }); mockPost.mockReturnValueOnce(refreshPromise as never); // Trigger a 401 to start refresh const authRequest = http.get("https://api.example.com/auth", { authenticated: true }); await vi.waitFor(() => { expect(mockPost).toHaveBeenCalledTimes(1); }); // Public request should go through immediately, not wait for refresh const publicResult = await http.get("https://api.example.com/health", { authenticated: false }); expect(publicResult).toEqual({ data: "public" }); resolveRefresh(REFRESH_RESPONSE); await authRequest; }); }); describe("accessTokenProvider", () => { test("should use provider token instead of stored token", async () => { session.accessTokenProvider = vi.fn().mockResolvedValue("provider-token"); mockGet.mockResolvedValueOnce({ ok: true }); await http.get("https://api.example.com/resource", { authenticated: true }); expect(mockGet).toHaveBeenCalledWith("https://api.example.com/resource", { headers: { Authorization: "Bearer provider-token" } }, {}); }); }); describe("custom headers and options", () => { test("should merge custom headers with auth header", async () => { mockGet.mockResolvedValueOnce({ ok: true }); await http.get("https://api.example.com/resource", { authenticated: true, headers: { "X-Custom": "value" }, }); expect(mockGet).toHaveBeenCalledWith( "https://api.example.com/resource", { headers: { "X-Custom": "value", Authorization: "Bearer test-access-token" } }, {} ); }); test("should pass through custom options to http-helpers", async () => { mockPost.mockResolvedValueOnce({ ok: true }); await http.post( "https://api.example.com/resource", { data: 1 }, { authenticated: true, useAPIKey: true, timeout: 5000, } ); expect(mockPost).toHaveBeenCalledWith( "https://api.example.com/resource", { data: 1 }, { headers: { Authorization: "Bearer test-access-token" } }, { useAPIKey: true, timeout: 5000 } ); }); }); }); describe("HttpClient with IHttpSessionAuthProvider", () => { let provider: IHttpSessionAuthProvider; let http: HttpClient; beforeEach(() => { vi.clearAllMocks(); provider = { getAccessToken: vi.fn().mockResolvedValue("custom-token"), handleUnauthorized: vi.fn().mockResolvedValue("refreshed-custom-token"), }; http = new HttpClient(provider); }); test("should call handleUnauthorized on 401 and retry with new token", async () => { mockGet.mockRejectedValueOnce(make401Response()).mockResolvedValueOnce({ data: "retried" }); const result = await http.get("https://api.example.com/protected", { authenticated: true }); expect(result).toEqual({ data: "retried" }); expect(provider.handleUnauthorized).toHaveBeenCalledOnce(); expect(mockGet).toHaveBeenCalledTimes(2); const retryHeaders = mockGet.mock.calls[1][1]; expect(retryHeaders).toEqual({ headers: { Authorization: "Bearer refreshed-custom-token" } }); }); test("should not call handleUnauthorized on non-401 errors", async () => { mockGet.mockRejectedValueOnce(make500Response()); await expect(http.get("https://api.example.com/protected", { authenticated: true })).rejects.toBeInstanceOf(Response); expect(provider.handleUnauthorized).not.toHaveBeenCalled(); }); test("concurrent 401s should deduplicate handleUnauthorized calls", async () => { mockGet .mockRejectedValueOnce(make401Response()) .mockRejectedValueOnce(make401Response()) .mockResolvedValueOnce({ data: "a" }) .mockResolvedValueOnce({ data: "b" }); const [resultA, resultB] = await Promise.all([ http.get("https://api.example.com/a", { authenticated: true }), http.get("https://api.example.com/b", { authenticated: true }), ]); expect(resultA).toEqual({ data: "a" }); expect(resultB).toEqual({ data: "b" }); expect(provider.handleUnauthorized).toHaveBeenCalledOnce(); }); test("should handle null token from getAccessToken gracefully", async () => { (provider.getAccessToken as ReturnType).mockResolvedValueOnce(null); mockGet.mockResolvedValueOnce({ ok: true }); await http.get("https://api.example.com/resource", { authenticated: true }); expect(mockGet).toHaveBeenCalledWith("https://api.example.com/resource", { headers: {} }, {}); }); test("all HTTP methods should work with IHttpAuthProvider", async () => { mockPost.mockResolvedValueOnce({ created: true }); mockPut.mockResolvedValueOnce({ updated: true }); mockPatch.mockResolvedValueOnce({ patched: true }); mockDelete.mockResolvedValueOnce({ deleted: true }); await http.post("https://api.example.com/items", { name: "x" }, { authenticated: true }); await http.put("https://api.example.com/items/1", { name: "y" }, { authenticated: true }); await http.patch("https://api.example.com/items/1", { name: "z" }, { authenticated: true }); await http.delete("https://api.example.com/items/1", {}, { authenticated: true }); expect(provider.getAccessToken).toHaveBeenCalledTimes(4); for (const mock of [mockPost, mockPut, mockPatch, mockDelete]) { expect(mock.mock.calls[0][2]).toEqual({ headers: { Authorization: "Bearer custom-token" } }); } }); });