File
Blob: tests/client/lib/api.dom.test.ts
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | import { createUser } from "@tests/client/util/fixtures"; |
| 3 | import { SESSION_MODES, STORAGE_KEYS } from "@/client/lib/constants"; |
| 4 | import { D1_BOOKMARK_HEADER } from "@/shared/bookmark"; |
| 5 | |
| 6 | let useAuthStore: typeof import("@/client/stores/auth-store").useAuthStore; |
| 7 | let selectHasLocalSession: typeof import("@/client/stores/auth-store").selectHasLocalSession; |
| 8 | let api: typeof import("@/client/lib/api").api; |
| 9 | |
| 10 | const mockFetch = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise<Response>>(); |
| 11 | |
| 12 | beforeEach(async () => { |
| 13 | localStorage.clear(); |
| 14 | mockFetch.mockReset(); |
| 15 | vi.stubGlobal("fetch", mockFetch); |
| 16 | vi.resetModules(); |
| 17 | const authMod = await import("@/client/stores/auth-store"); |
| 18 | const apiMod = await import("@/client/lib/api"); |
| 19 | useAuthStore = authMod.useAuthStore; |
| 20 | selectHasLocalSession = authMod.selectHasLocalSession; |
| 21 | api = apiMod.api; |
| 22 | }); |
| 23 | |
| 24 | afterEach(() => { |
| 25 | localStorage.clear(); |
| 26 | vi.restoreAllMocks(); |
| 27 | vi.unstubAllGlobals(); |
| 28 | }); |
| 29 | |
| 30 | function jsonResponse(status: number, body: unknown, headers?: Record<string, string>): Response { |
| 31 | return new Response(JSON.stringify(body), { |
| 32 | status, |
| 33 | headers: { "Content-Type": "application/json", ...headers }, |
| 34 | }); |
| 35 | } |
| 36 | |
| 37 | describe("apiFetch auto-refresh", () => { |
| 38 | const user = createUser(); |
| 39 | const refreshedUser = createUser({ name: "Refreshed" }); |
| 40 | |
| 41 | it.each([ |
| 42 | ["401", 401, { error: "unauthorized", message: "expired" }], |
| 43 | ["403 unauthorized", 403, { error: "unauthorized", message: "token invalid" }], |
| 44 | ])("on %s, refreshes then retries the original request", async (_label, status, errBody) => { |
| 45 | // Set up authenticated state |
| 46 | useAuthStore.getState().setAuth("old-token", user); |
| 47 | |
| 48 | // Call 1: original request returns 401/403 |
| 49 | // Call 2: refresh succeeds |
| 50 | // Call 3: retried request succeeds |
| 51 | mockFetch |
| 52 | .mockResolvedValueOnce(jsonResponse(status, errBody)) |
| 53 | .mockResolvedValueOnce(jsonResponse(200, { accessToken: "new-token", user: refreshedUser })) |
| 54 | .mockResolvedValueOnce(jsonResponse(200, { workspaces: [] })); |
| 55 | |
| 56 | const result = await api.workspaces.list(); |
| 57 | expect(result).toEqual([]); |
| 58 | |
| 59 | // Auth store should be updated with new token |
| 60 | const state = useAuthStore.getState(); |
| 61 | expect(state.accessToken).toBe("new-token"); |
| 62 | expect(state.user).toEqual(refreshedUser); |
| 63 | expect(state.sessionMode).toBe(SESSION_MODES.AUTHENTICATED); |
| 64 | |
| 65 | // The retry should use the new token |
| 66 | const retryCall = mockFetch.mock.calls[2]; |
| 67 | expect(new Headers(retryCall[1]?.headers).get("Authorization")).toBe("Bearer new-token"); |
| 68 | }); |
| 69 | |
| 70 | it("marks session expired when refresh returns non-ok", async () => { |
| 71 | useAuthStore.getState().setAuth("old-token", user); |
| 72 | |
| 73 | mockFetch |
| 74 | .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) |
| 75 | .mockResolvedValueOnce(jsonResponse(401, { error: "invalid_refresh", message: "bad token" })); |
| 76 | |
| 77 | await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); |
| 78 | |
| 79 | const state = useAuthStore.getState(); |
| 80 | expect(state.accessToken).toBeNull(); |
| 81 | expect(state.user).toEqual(user); |
| 82 | expect(state.sessionMode).toBe(SESSION_MODES.EXPIRED); |
| 83 | }); |
| 84 | |
| 85 | it("transitions to LOCAL_ONLY on network error during refresh", async () => { |
| 86 | useAuthStore.getState().setAuth("old-token", user); |
| 87 | |
| 88 | mockFetch |
| 89 | .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) |
| 90 | .mockRejectedValueOnce(new TypeError("Failed to fetch")); |
| 91 | |
| 92 | await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); |
| 93 | |
| 94 | const state = useAuthStore.getState(); |
| 95 | expect(state.sessionMode).toBe(SESSION_MODES.LOCAL_ONLY); |
| 96 | expect(state.user).toEqual(user); |
| 97 | expect(selectHasLocalSession(state)).toBe(true); |
| 98 | }); |
| 99 | |
| 100 | it("does not downgrade from LOCAL_ONLY on repeated network errors", async () => { |
| 101 | useAuthStore.getState().setAuth("old-token", user); |
| 102 | useAuthStore.getState().setSessionMode(SESSION_MODES.LOCAL_ONLY); |
| 103 | |
| 104 | mockFetch |
| 105 | .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) |
| 106 | .mockRejectedValueOnce(new TypeError("Failed to fetch")); |
| 107 | |
| 108 | await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); |
| 109 | |
| 110 | // Should stay LOCAL_ONLY, not accidentally transition to something else |
| 111 | expect(useAuthStore.getState().sessionMode).toBe(SESSION_MODES.LOCAL_ONLY); |
| 112 | }); |
| 113 | |
| 114 | it("does not attempt refresh for the refresh endpoint itself", async () => { |
| 115 | mockFetch.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "bad refresh" })); |
| 116 | |
| 117 | await expect(api.auth.refresh()).rejects.toEqual(expect.objectContaining({ error: "unauthorized" })); |
| 118 | |
| 119 | expect(mockFetch).toHaveBeenCalledTimes(1); |
| 120 | }); |
| 121 | |
| 122 | it("throws retry error when refresh succeeds but retry fails for non-auth reason", async () => { |
| 123 | useAuthStore.getState().setAuth("old-token", user); |
| 124 | |
| 125 | mockFetch |
| 126 | .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) |
| 127 | .mockResolvedValueOnce(jsonResponse(200, { accessToken: "new-token", user: refreshedUser })) |
| 128 | .mockResolvedValueOnce(jsonResponse(404, { error: "not_found", message: "workspace gone" })); |
| 129 | |
| 130 | await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "not_found" })); |
| 131 | |
| 132 | // Auth should still be updated from the successful refresh |
| 133 | expect(useAuthStore.getState().accessToken).toBe("new-token"); |
| 134 | }); |
| 135 | |
| 136 | it("propagates 403 errors that are not unauthorized", async () => { |
| 137 | useAuthStore.getState().setAuth("tok", user); |
| 138 | |
| 139 | mockFetch.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden", message: "no access" })); |
| 140 | |
| 141 | await expect(api.workspaces.list()).rejects.toEqual(expect.objectContaining({ error: "forbidden" })); |
| 142 | |
| 143 | // Only 1 call — no refresh attempted for non-unauthorized 403 |
| 144 | expect(mockFetch).toHaveBeenCalledTimes(1); |
| 145 | }); |
| 146 | |
| 147 | it("does not throw when reading or writing bookmark storage fails", async () => { |
| 148 | vi.spyOn(localStorage, "getItem").mockImplementation(() => { |
| 149 | throw new Error("blocked"); |
| 150 | }); |
| 151 | vi.spyOn(localStorage, "setItem").mockImplementation(() => { |
| 152 | throw new Error("blocked"); |
| 153 | }); |
| 154 | |
| 155 | mockFetch.mockResolvedValueOnce(jsonResponse(200, { workspaces: [] }, { [D1_BOOKMARK_HEADER]: "bookmark-123" })); |
| 156 | |
| 157 | await expect(api.workspaces.list()).resolves.toEqual([]); |
| 158 | expect(mockFetch).toHaveBeenCalledTimes(1); |
| 159 | }); |
| 160 | }); |
| 161 | |
| 162 | describe("refreshSession bookmark persistence", () => { |
| 163 | const user = createUser(); |
| 164 | |
| 165 | it("persists the response D1 bookmark on successful refresh", async () => { |
| 166 | mockFetch.mockResolvedValueOnce( |
| 167 | jsonResponse(200, { accessToken: "fresh-token", user }, { [D1_BOOKMARK_HEADER]: "post-oidc-bookmark" }), |
| 168 | ); |
| 169 | |
| 170 | const { refreshSession } = await import("@/client/lib/api"); |
| 171 | const result = await refreshSession(); |
| 172 | expect(result.ok).toBe(true); |
| 173 | expect(localStorage.getItem(STORAGE_KEYS.D1_BOOKMARK)).toBe("post-oidc-bookmark"); |
| 174 | }); |
| 175 | |
| 176 | it("leaves stored bookmark untouched on failure", async () => { |
| 177 | localStorage.setItem(STORAGE_KEYS.D1_BOOKMARK, "preserved"); |
| 178 | mockFetch.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "bad" })); |
| 179 | |
| 180 | const { refreshSession } = await import("@/client/lib/api"); |
| 181 | const result = await refreshSession(); |
| 182 | expect(result.ok).toBe(false); |
| 183 | expect(localStorage.getItem(STORAGE_KEYS.D1_BOOKMARK)).toBe("preserved"); |
| 184 | }); |
| 185 | }); |
| 186 | |
| 187 | describe("uploads.uploadData", () => { |
| 188 | const user = createUser(); |
| 189 | const refreshedUser = createUser({ name: "Refreshed" }); |
| 190 | |
| 191 | it("targets the absolute /uploads/:id/data path (no /api/v1 prefix)", async () => { |
| 192 | useAuthStore.getState().setAuth("tok", user); |
| 193 | mockFetch.mockResolvedValueOnce(jsonResponse(200, { ok: true })); |
| 194 | |
| 195 | const file = new File(["payload"], "x.png", { type: "image/png" }); |
| 196 | await api.uploads.uploadData("/uploads/abc/data", file); |
| 197 | |
| 198 | expect(mockFetch).toHaveBeenCalledTimes(1); |
| 199 | expect(mockFetch.mock.calls[0][0]).toBe("/uploads/abc/data"); |
| 200 | const init = mockFetch.mock.calls[0][1]; |
| 201 | expect(init?.method).toBe("PUT"); |
| 202 | expect(init?.body).toBe(file); |
| 203 | expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer tok"); |
| 204 | expect(new Headers(init?.headers).get("Content-Type")).toBe("image/png"); |
| 205 | }); |
| 206 | |
| 207 | it("refreshes on 401 unauthorized and retries the PUT with the same File body", async () => { |
| 208 | useAuthStore.getState().setAuth("old-token", user); |
| 209 | const file = new File(["payload"], "x.png", { type: "image/png" }); |
| 210 | |
| 211 | mockFetch |
| 212 | .mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized", message: "expired" })) |
| 213 | .mockResolvedValueOnce(jsonResponse(200, { accessToken: "new-token", user: refreshedUser })) |
| 214 | .mockResolvedValueOnce(jsonResponse(200, { ok: true })); |
| 215 | |
| 216 | await expect(api.uploads.uploadData("/uploads/abc/data", file)).resolves.toEqual({ ok: true }); |
| 217 | |
| 218 | expect(mockFetch).toHaveBeenCalledTimes(3); |
| 219 | const retryCall = mockFetch.mock.calls[2]; |
| 220 | expect(retryCall[0]).toBe("/uploads/abc/data"); |
| 221 | expect(new Headers(retryCall[1]?.headers).get("Authorization")).toBe("Bearer new-token"); |
| 222 | expect(retryCall[1]?.body).toBe(file); |
| 223 | expect(useAuthStore.getState().accessToken).toBe("new-token"); |
| 224 | }); |
| 225 | |
| 226 | it("does not refresh on 403 forbidden when the share token is rejected", async () => { |
| 227 | useAuthStore.getState().setAuth("tok", user); |
| 228 | mockFetch.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden", message: "no edit" })); |
| 229 | |
| 230 | const file = new File(["payload"], "x.png", { type: "image/png" }); |
| 231 | await expect(api.uploads.uploadData("/uploads/abc/data", file, "share-token")).rejects.toEqual( |
| 232 | expect.objectContaining({ error: "forbidden" }), |
| 233 | ); |
| 234 | |
| 235 | expect(mockFetch).toHaveBeenCalledTimes(1); |
| 236 | expect(mockFetch.mock.calls[0][0]).toBe("/uploads/abc/data?share=share-token"); |
| 237 | }); |
| 238 | }); |
| 239 | |
| 240 | describe("pages.snapshot", () => { |
| 241 | it("returns binary snapshot bytes on 200", async () => { |
| 242 | const bytes = Uint8Array.from([5, 4, 3, 2]); |
| 243 | mockFetch.mockResolvedValueOnce( |
| 244 | new Response(bytes, { |
| 245 | status: 200, |
| 246 | headers: { "Content-Type": "application/octet-stream" }, |
| 247 | }), |
| 248 | ); |
| 249 | |
| 250 | await expect(api.pages.snapshot("ws-1", "page-1")).resolves.toEqual({ |
| 251 | kind: "found", |
| 252 | snapshot: bytes.buffer, |
| 253 | }); |
| 254 | }); |
| 255 | |
| 256 | it("returns missing on 204", async () => { |
| 257 | mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); |
| 258 | |
| 259 | await expect(api.pages.snapshot("ws-1", "page-1")).resolves.toEqual({ |
| 260 | kind: "missing", |
| 261 | }); |
| 262 | }); |
| 263 | }); |