import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createUser } from "@tests/client/util/fixtures"; import { SESSION_MODES, STORAGE_KEYS } from "@/client/lib/constants"; import { D1_BOOKMARK_HEADER } from "@/shared/bookmark"; let useAuthStore: typeof import("@/client/stores/auth-store").useAuthStore; let selectHasLocalSession: typeof import("@/client/stores/auth-store").selectHasLocalSession; let api: typeof import("@/client/lib/api").api; const mockFetch = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>(); beforeEach(async () => { localStorage.clear(); mockFetch.mockReset(); vi.stubGlobal("fetch", mockFetch); vi.resetModules(); const authMod = await import("@/client/stores/auth-store"); const apiMod = await import("@/client/lib/api"); useAuthStore = authMod.useAuthStore; selectHasLocalSession = authMod.selectHasLocalSession; api = apiMod.api; }); afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function jsonResponse(status: number, body: unknown, headers?: Record): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...headers }, }); } describe("apiFetch auto-refresh", () => { const user = createUser(); const refreshedUser = createUser({ name: "Refreshed" }); it.each([ ["401", 401, { error: "unauthorized", message: "expired" }], ["403 unauthorized", 403, { error: "unauthorized", message: "token invalid" }], ])("on %s, refreshes then retries the original request", async (_label, status, errBody) => { // Set up authenticated state useAuthStore.getState().setAuth("old-token", user); // Call 1: original request returns 401/403 // Call 2: refresh succeeds // Call 3: retried request succeeds mockFetch .mockResolvedValueOnce(jsonResponse(status, errBody)) .mockResolvedValueOnce(jsonResponse(200, { accessToken: "new-token", user: refreshedUser })) .mockResolvedValueOnce(jsonResponse(200, { workspaces: [] })); const result = await api.workspaces.list(); expect(result).toEqual([]); // Auth store should be updated with new token const state = useAuthStore.getState(); expect(state.accessToken).toBe("new-token"); expect(state.user).toEqual(refreshedUser); expect(state.sessionMode).toBe(SESSION_MODES.AUTHENTICATED); // The retry should use the new token const retryCall = mockFetch.mock.calls[2]; expect(new Headers(retryCall[1]?.headers).get("Authorization")).toBe("Bearer new-token"); }); it("marks session expired when refresh returns non-ok", async () => { useAuthStore.getState().setAuth("old-token", user); mockFetch .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) .mockResolvedValueOnce(jsonResponse(401, { error: "invalid_refresh", message: "bad token" })); await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); const state = useAuthStore.getState(); expect(state.accessToken).toBeNull(); expect(state.user).toEqual(user); expect(state.sessionMode).toBe(SESSION_MODES.EXPIRED); }); it("transitions to LOCAL_ONLY on network error during refresh", async () => { useAuthStore.getState().setAuth("old-token", user); mockFetch .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) .mockRejectedValueOnce(new TypeError("Failed to fetch")); await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); const state = useAuthStore.getState(); expect(state.sessionMode).toBe(SESSION_MODES.LOCAL_ONLY); expect(state.user).toEqual(user); expect(selectHasLocalSession(state)).toBe(true); }); it("does not downgrade from LOCAL_ONLY on repeated network errors", async () => { useAuthStore.getState().setAuth("old-token", user); useAuthStore.getState().setSessionMode(SESSION_MODES.LOCAL_ONLY); mockFetch .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) .mockRejectedValueOnce(new TypeError("Failed to fetch")); await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); // Should stay LOCAL_ONLY, not accidentally transition to something else expect(useAuthStore.getState().sessionMode).toBe(SESSION_MODES.LOCAL_ONLY); }); it("does not attempt refresh for the refresh endpoint itself", async () => { mockFetch.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "bad refresh" })); await expect(api.auth.refresh()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); expect(mockFetch).toHaveBeenCalledTimes(1); }); it("throws retry error when refresh succeeds but retry fails for non-auth reason", async () => { useAuthStore.getState().setAuth("old-token", user); mockFetch .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) .mockResolvedValueOnce(jsonResponse(200, { accessToken: "new-token", user: refreshedUser })) .mockResolvedValueOnce(jsonResponse(404, { error: "not_found", message: "workspace gone" })); await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "not_found" })); // Auth should still be updated from the successful refresh expect(useAuthStore.getState().accessToken).toBe("new-token"); }); it("propagates 403 errors that are not unauthorized", async () => { useAuthStore.getState().setAuth("tok", user); mockFetch.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden", message: "no access" })); await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "forbidden" })); // Only 1 call — no refresh attempted for non-unauthorized 403 expect(mockFetch).toHaveBeenCalledTimes(1); }); it("does not throw when reading or writing bookmark storage fails", async () => { vi.spyOn(localStorage, "getItem").mockImplementation(() => { throw new Error("blocked"); }); vi.spyOn(localStorage, "setItem").mockImplementation(() => { throw new Error("blocked"); }); mockFetch.mockResolvedValueOnce(jsonResponse(200, { workspaces: [] }, { [D1_BOOKMARK_HEADER]: "bookmark-123" })); await expect(api.workspaces.list()).resolves.toEqual([]); expect(mockFetch).toHaveBeenCalledTimes(1); }); }); describe("refreshSession bookmark persistence", () => { const user = createUser(); it("persists the response D1 bookmark on successful refresh", async () => { mockFetch.mockResolvedValueOnce( jsonResponse(200, { accessToken: "fresh-token", user }, { [D1_BOOKMARK_HEADER]: "post-oidc-bookmark" }), ); const { refreshSession } = await import("@/client/lib/api"); const result = await refreshSession(); expect(result.ok).toBe(true); expect(localStorage.getItem(STORAGE_KEYS.D1_BOOKMARK)).toBe("post-oidc-bookmark"); }); it("leaves stored bookmark untouched on failure", async () => { localStorage.setItem(STORAGE_KEYS.D1_BOOKMARK, "preserved"); mockFetch.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "bad" })); const { refreshSession } = await import("@/client/lib/api"); const result = await refreshSession(); expect(result.ok).toBe(false); expect(localStorage.getItem(STORAGE_KEYS.D1_BOOKMARK)).toBe("preserved"); }); }); describe("uploads.uploadData", () => { const user = createUser(); const refreshedUser = createUser({ name: "Refreshed" }); it("targets the absolute /uploads/:id/data path (no /api/v1 prefix)", async () => { useAuthStore.getState().setAuth("tok", user); mockFetch.mockResolvedValueOnce(jsonResponse(200, { ok: true })); const file = new File(["payload"], "x.png", { type: "image/png" }); await api.uploads.uploadData("/uploads/abc/data", file); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch.mock.calls[0][0]).toBe("/uploads/abc/data"); const init = mockFetch.mock.calls[0][1]; expect(init?.method).toBe("PUT"); expect(init?.body).toBe(file); expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer tok"); expect(new Headers(init?.headers).get("Content-Type")).toBe("image/png"); }); it("refreshes on 401 unauthorized and retries the PUT with the same File body", async () => { useAuthStore.getState().setAuth("old-token", user); const file = new File(["payload"], "x.png", { type: "image/png" }); mockFetch .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) .mockResolvedValueOnce(jsonResponse(200, { accessToken: "new-token", user: refreshedUser })) .mockResolvedValueOnce(jsonResponse(200, { ok: true })); await expect(api.uploads.uploadData("/uploads/abc/data", file)).resolves.toEqual({ ok: true }); expect(mockFetch).toHaveBeenCalledTimes(3); const retryCall = mockFetch.mock.calls[2]; expect(retryCall[0]).toBe("/uploads/abc/data"); expect(new Headers(retryCall[1]?.headers).get("Authorization")).toBe("Bearer new-token"); expect(retryCall[1]?.body).toBe(file); expect(useAuthStore.getState().accessToken).toBe("new-token"); }); it("does not refresh on 403 forbidden when the share token is rejected", async () => { useAuthStore.getState().setAuth("tok", user); mockFetch.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden", message: "no edit" })); const file = new File(["payload"], "x.png", { type: "image/png" }); await expect(api.uploads.uploadData("/uploads/abc/data", file, "share-token")).rejects.toEqual( expect.objectContaining({ error: "forbidden" }), ); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch.mock.calls[0][0]).toBe("/uploads/abc/data?share=share-token"); }); }); describe("pages.snapshot", () => { it("returns binary snapshot bytes on 200", async () => { const bytes = Uint8Array.from([5, 4, 3, 2]); mockFetch.mockResolvedValueOnce( new Response(bytes, { status: 200, headers: { "Content-Type": "application/octet-stream" }, }), ); await expect(api.pages.snapshot("ws-1", "page-1")).resolves.toEqual({ kind: "found", snapshot: bytes.buffer, }); }); it("returns missing on 204", async () => { mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); await expect(api.pages.snapshot("ws-1", "page-1")).resolves.toEqual({ kind: "missing", }); }); });