Skip to content
File

Blob: tests/client/lib/api.dom.test.ts

typescript264 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { createUser } from "@tests/client/util/fixtures";
3import { SESSION_MODES, STORAGE_KEYS } from "@/client/lib/constants";
4import { D1_BOOKMARK_HEADER } from "@/shared/bookmark";
5 
6let useAuthStore: typeof import("@/client/stores/auth-store").useAuthStore;
7let selectHasLocalSession: typeof import("@/client/stores/auth-store").selectHasLocalSession;
8let api: typeof import("@/client/lib/api").api;
9 
10const mockFetch = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise<Response>>();
11 
12beforeEach(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 
24afterEach(() => {
25 localStorage.clear();
26 vi.restoreAllMocks();
27 vi.unstubAllGlobals();
28});
29 
30function 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 
37describe("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 
162describe("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 
187describe("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 
240describe("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});