@gusnips/react 0.9.33 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -10
- package/dist/api-client.js.map +1 -1
- package/dist/api-error.d.ts.map +1 -1
- package/dist/api-error.js.map +1 -1
- package/dist/copy.js.map +1 -1
- package/dist/error-boundary.d.ts.map +1 -1
- package/dist/guards.d.ts.map +1 -1
- package/dist/guards.js.map +1 -1
- package/dist/i18n.d.ts +3 -3
- package/dist/i18n.d.ts.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/oauth.d.ts +25 -13
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +164 -37
- package/dist/oauth.js.map +1 -1
- package/dist/query.js.map +1 -1
- package/dist/supabase-session.d.ts +6 -0
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +24 -11
- package/dist/supabase-session.js.map +1 -1
- package/dist/ui/combobox.d.ts +9 -9
- package/dist/ui/combobox.d.ts.map +1 -1
- package/dist/ui/combobox.js.map +1 -1
- package/dist/ui/dialog.d.ts +6 -6
- package/dist/ui/dialog.d.ts.map +1 -1
- package/dist/ui/menu.d.ts +9 -9
- package/dist/ui/menu.d.ts.map +1 -1
- package/dist/ui/select.js +2 -6
- package/dist/ui/select.js.map +1 -1
- package/package.json +3 -3
- package/src/guards.tsx +3 -1
- package/src/oauth.test.ts +726 -26
- package/src/oauth.ts +222 -48
- package/src/supabase-session.test.ts +31 -0
- package/src/supabase-session.ts +30 -12
- package/src/ui/combobox.tsx +2 -1
package/src/oauth.test.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
focusManager,
|
|
4
|
+
keepPreviousData,
|
|
5
|
+
MutationCache,
|
|
6
|
+
onlineManager,
|
|
7
|
+
QueryClient,
|
|
8
|
+
QueryClientProvider,
|
|
9
|
+
} from "@tanstack/react-query";
|
|
10
|
+
import { act, createElement, StrictMode, useLayoutEffect, type ChangeEvent } from "react";
|
|
4
11
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
12
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
13
|
import { createApiClient } from "./api-client.ts";
|
|
7
14
|
import {
|
|
8
15
|
createOAuthConsoleApi,
|
|
9
16
|
formatUserCode,
|
|
17
|
+
OAuthOperationChangedError,
|
|
10
18
|
useConsentRequest,
|
|
11
19
|
useDeviceApproval,
|
|
12
20
|
useDeviceCodeField,
|
|
@@ -15,30 +23,65 @@ import {
|
|
|
15
23
|
type DeviceApi,
|
|
16
24
|
type DeviceView,
|
|
17
25
|
type OAuthRefusal,
|
|
26
|
+
type OAuthScope,
|
|
18
27
|
} from "./oauth.ts";
|
|
19
28
|
|
|
20
29
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
21
30
|
|
|
22
31
|
const roots = new Set<Root>();
|
|
32
|
+
const clients = new Set<QueryClient>();
|
|
23
33
|
afterEach(() => {
|
|
24
34
|
for (const root of roots) act(() => root.unmount());
|
|
25
35
|
roots.clear();
|
|
36
|
+
for (const client of clients) client.clear();
|
|
37
|
+
clients.clear();
|
|
38
|
+
focusManager.setFocused(undefined);
|
|
39
|
+
onlineManager.setOnline(true);
|
|
26
40
|
vi.restoreAllMocks();
|
|
27
41
|
});
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
const SCOPE: OAuthScope = { key: ["account-a", "workspace-1", 0], isCurrent: () => true };
|
|
44
|
+
|
|
45
|
+
/** The app's identity generation advances even when an account leaves and comes straight back. */
|
|
46
|
+
function identityBoundary() {
|
|
47
|
+
let generation = 0;
|
|
48
|
+
return {
|
|
49
|
+
capture(account = "account-a"): OAuthScope {
|
|
50
|
+
const started = generation;
|
|
51
|
+
return { key: [account, "workspace-1", started], isCurrent: () => started === generation };
|
|
52
|
+
},
|
|
53
|
+
change() {
|
|
54
|
+
generation++;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Renders `hook` under a real query client and keeps its latest answer in `current`. */
|
|
60
|
+
function mount<T>(
|
|
61
|
+
hook: () => T,
|
|
62
|
+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
|
63
|
+
strict = false,
|
|
64
|
+
) {
|
|
31
65
|
const seen: T[] = [];
|
|
32
|
-
|
|
66
|
+
clients.add(client);
|
|
33
67
|
function Probe() {
|
|
34
68
|
seen.push(hook());
|
|
35
69
|
return null;
|
|
36
70
|
}
|
|
37
71
|
const root = createRoot(document.createElement("div"));
|
|
38
72
|
roots.add(root);
|
|
39
|
-
|
|
73
|
+
const render = () => {
|
|
74
|
+
const page = createElement(QueryClientProvider, { client }, createElement(Probe));
|
|
75
|
+
act(() => root.render(strict ? createElement(StrictMode, null, page) : page));
|
|
76
|
+
};
|
|
77
|
+
render();
|
|
40
78
|
return {
|
|
41
79
|
client,
|
|
80
|
+
render,
|
|
81
|
+
unmount() {
|
|
82
|
+
act(() => root.unmount());
|
|
83
|
+
roots.delete(root);
|
|
84
|
+
},
|
|
42
85
|
get current(): T {
|
|
43
86
|
const last = seen.at(-1);
|
|
44
87
|
if (last === undefined) throw new Error("The hook never rendered");
|
|
@@ -47,6 +90,14 @@ function mount<T>(hook: () => T) {
|
|
|
47
90
|
};
|
|
48
91
|
}
|
|
49
92
|
|
|
93
|
+
function deferred<T>() {
|
|
94
|
+
let resolve!: (answer: T) => void;
|
|
95
|
+
const promise = new Promise<T>((release) => {
|
|
96
|
+
resolve = release;
|
|
97
|
+
});
|
|
98
|
+
return { promise, resolve };
|
|
99
|
+
}
|
|
100
|
+
|
|
50
101
|
/**
|
|
51
102
|
* Lets pending promises and the renders they cause finish. react-query hands its updates to a
|
|
52
103
|
* scheduler that runs on a later task, so one tick is not always enough.
|
|
@@ -82,15 +133,19 @@ describe("useConsentRequest", () => {
|
|
|
82
133
|
}),
|
|
83
134
|
),
|
|
84
135
|
};
|
|
85
|
-
const page = mount(() => useConsentRequest(api, "?client_id=x&state=s"));
|
|
136
|
+
const page = mount(() => useConsentRequest(api, "?client_id=x&state=s", SCOPE));
|
|
86
137
|
expect(page.current.state).toEqual({ status: "loading" });
|
|
87
138
|
await settle();
|
|
88
139
|
expect(page.current.state).toEqual({ status: "ready", view: VIEW });
|
|
89
|
-
expect(api.describe).toHaveBeenCalledWith("client_id=x&state=s"
|
|
140
|
+
expect(api.describe).toHaveBeenCalledWith("client_id=x&state=s", {
|
|
141
|
+
signal: expect.any(AbortSignal),
|
|
142
|
+
});
|
|
90
143
|
|
|
91
144
|
act(() => page.current.approve("ws-1"));
|
|
92
145
|
await settle();
|
|
93
|
-
expect(api.approve).toHaveBeenCalledWith("client_id=x&state=s", "ws-1"
|
|
146
|
+
expect(api.approve).toHaveBeenCalledWith("client_id=x&state=s", "ws-1", {
|
|
147
|
+
signal: expect.any(AbortSignal),
|
|
148
|
+
});
|
|
94
149
|
expect(page.current.leaving).toBe(true);
|
|
95
150
|
|
|
96
151
|
release();
|
|
@@ -105,7 +160,7 @@ describe("useConsentRequest", () => {
|
|
|
105
160
|
describe: async () => ({ ...VIEW, spaces: ["ws-1", "ws-2"] }),
|
|
106
161
|
approve: async () => ({ ok: true, redirectTo: "https://claude.ai/cb" }),
|
|
107
162
|
};
|
|
108
|
-
const page = mount(() => useConsentRequest(api, "?client_id=x"));
|
|
163
|
+
const page = mount(() => useConsentRequest(api, "?client_id=x", SCOPE));
|
|
109
164
|
await settle();
|
|
110
165
|
const { state } = page.current;
|
|
111
166
|
expect(state.status === "ready" ? state.view.spaces : null).toEqual(["ws-1", "ws-2"]);
|
|
@@ -114,7 +169,7 @@ describe("useConsentRequest", () => {
|
|
|
114
169
|
it("shows a refusal as a state and never redirects on it", async () => {
|
|
115
170
|
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
116
171
|
const page = mount(() =>
|
|
117
|
-
useConsentRequest({ describe: async () => REFUSAL, approve: vi.fn() }, "?client_id=x"),
|
|
172
|
+
useConsentRequest({ describe: async () => REFUSAL, approve: vi.fn() }, "?client_id=x", SCOPE),
|
|
118
173
|
);
|
|
119
174
|
await settle();
|
|
120
175
|
expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
|
|
@@ -124,7 +179,7 @@ describe("useConsentRequest", () => {
|
|
|
124
179
|
it("turns a refusal from approve into the refused state, and stays put", async () => {
|
|
125
180
|
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
126
181
|
const page = mount(() =>
|
|
127
|
-
useConsentRequest({ describe: async () => VIEW, approve: async () => REFUSAL }, ""),
|
|
182
|
+
useConsentRequest({ describe: async () => VIEW, approve: async () => REFUSAL }, "", SCOPE),
|
|
128
183
|
);
|
|
129
184
|
await settle();
|
|
130
185
|
act(() => page.current.approve());
|
|
@@ -147,6 +202,7 @@ describe("useConsentRequest", () => {
|
|
|
147
202
|
approve: vi.fn(),
|
|
148
203
|
},
|
|
149
204
|
"?a=1",
|
|
205
|
+
SCOPE,
|
|
150
206
|
),
|
|
151
207
|
);
|
|
152
208
|
await settle();
|
|
@@ -168,6 +224,7 @@ describe("useConsentRequest", () => {
|
|
|
168
224
|
},
|
|
169
225
|
},
|
|
170
226
|
"",
|
|
227
|
+
SCOPE,
|
|
171
228
|
),
|
|
172
229
|
);
|
|
173
230
|
await settle();
|
|
@@ -197,15 +254,17 @@ function deviceApi(overrides: Partial<DeviceApi> = {}) {
|
|
|
197
254
|
describe("useDeviceApproval", () => {
|
|
198
255
|
it("starts from the link's code, asks the server, and stays approved after the yes", async () => {
|
|
199
256
|
const api = deviceApi();
|
|
200
|
-
const page = mount(() => useDeviceApproval(api, "?code=wdjb-mjht"));
|
|
257
|
+
const page = mount(() => useDeviceApproval(api, "?code=wdjb-mjht", SCOPE));
|
|
201
258
|
expect(page.current.code).toBe("WDJB-MJHT");
|
|
202
259
|
await settle();
|
|
203
260
|
expect(page.current.state).toEqual({ status: "ready", view: DEVICE });
|
|
204
|
-
expect(api.describe).toHaveBeenCalledWith("WDJBMJHT");
|
|
261
|
+
expect(api.describe).toHaveBeenCalledWith("WDJBMJHT", { signal: expect.any(AbortSignal) });
|
|
205
262
|
|
|
206
263
|
act(() => page.current.approve());
|
|
207
264
|
await settle();
|
|
208
|
-
expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve", undefined
|
|
265
|
+
expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve", undefined, {
|
|
266
|
+
signal: expect.any(AbortSignal),
|
|
267
|
+
});
|
|
209
268
|
expect(page.current.state).toEqual({ status: "approved" });
|
|
210
269
|
|
|
211
270
|
// A refetch on focus would now hear "unknown code". It must not be asked.
|
|
@@ -218,7 +277,7 @@ describe("useDeviceApproval", () => {
|
|
|
218
277
|
|
|
219
278
|
it("waits for all eight letters before asking, and flags a vowel at once", async () => {
|
|
220
279
|
const api = deviceApi();
|
|
221
|
-
const page = mount(() => useDeviceApproval(api, ""));
|
|
280
|
+
const page = mount(() => useDeviceApproval(api, "", SCOPE));
|
|
222
281
|
expect(page.current.state).toEqual({ status: "typing" });
|
|
223
282
|
act(() => page.current.setCode("wdjb m"));
|
|
224
283
|
expect(page.current.code).toBe("WDJB-M");
|
|
@@ -233,16 +292,18 @@ describe("useDeviceApproval", () => {
|
|
|
233
292
|
|
|
234
293
|
it("says no, and shows a code the server does not know as refused", async () => {
|
|
235
294
|
const api = deviceApi();
|
|
236
|
-
const said = mount(() => useDeviceApproval(api, "?code=WDJBMJHT"));
|
|
295
|
+
const said = mount(() => useDeviceApproval(api, "?code=WDJBMJHT", SCOPE));
|
|
237
296
|
await settle();
|
|
238
297
|
act(() => said.current.deny());
|
|
239
298
|
await settle();
|
|
240
299
|
expect(said.current.state).toEqual({ status: "denied" });
|
|
241
|
-
expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "deny", undefined
|
|
300
|
+
expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "deny", undefined, {
|
|
301
|
+
signal: expect.any(AbortSignal),
|
|
302
|
+
});
|
|
242
303
|
|
|
243
304
|
const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
|
|
244
305
|
const gone = mount(() =>
|
|
245
|
-
useDeviceApproval(deviceApi({ describe: async () => unknown }), "?code=WDJBMJHT"),
|
|
306
|
+
useDeviceApproval(deviceApi({ describe: async () => unknown }), "?code=WDJBMJHT", SCOPE),
|
|
246
307
|
);
|
|
247
308
|
await settle();
|
|
248
309
|
expect(gone.current.state).toEqual({ status: "refused", refusal: unknown });
|
|
@@ -251,7 +312,7 @@ describe("useDeviceApproval", () => {
|
|
|
251
312
|
it("shows a decision the server refused, and lets a new code start over", async () => {
|
|
252
313
|
const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
|
|
253
314
|
const api = deviceApi({ decide: async () => unknown });
|
|
254
|
-
const page = mount(() => useDeviceApproval(api, "?code=WDJBMJHT"));
|
|
315
|
+
const page = mount(() => useDeviceApproval(api, "?code=WDJBMJHT", SCOPE));
|
|
255
316
|
await settle();
|
|
256
317
|
act(() => page.current.approve());
|
|
257
318
|
await settle();
|
|
@@ -272,14 +333,16 @@ describe("useDeviceApproval with a choice", () => {
|
|
|
272
333
|
}),
|
|
273
334
|
);
|
|
274
335
|
const page = mount(() =>
|
|
275
|
-
useDeviceApproval({ describe: async () => DEVICE, decide }, "?code=WDJBMJHT"),
|
|
336
|
+
useDeviceApproval({ describe: async () => DEVICE, decide }, "?code=WDJBMJHT", SCOPE),
|
|
276
337
|
);
|
|
277
338
|
await settle();
|
|
278
339
|
expect(page.current.pending).toBeNull();
|
|
279
340
|
|
|
280
341
|
act(() => page.current.approve("ws-1"));
|
|
281
342
|
await settle();
|
|
282
|
-
expect(decide).toHaveBeenCalledWith("WDJBMJHT", "approve", "ws-1"
|
|
343
|
+
expect(decide).toHaveBeenCalledWith("WDJBMJHT", "approve", "ws-1", {
|
|
344
|
+
signal: expect.any(AbortSignal),
|
|
345
|
+
});
|
|
283
346
|
expect(page.current.pending).toBe("approve");
|
|
284
347
|
|
|
285
348
|
release();
|
|
@@ -293,11 +356,52 @@ describe("useDeviceApproval with a choice", () => {
|
|
|
293
356
|
const changeTo = (value: string) => ({ target: { value } }) as ChangeEvent<HTMLInputElement>;
|
|
294
357
|
|
|
295
358
|
describe("useDeviceCodeField", () => {
|
|
359
|
+
it("keeps every typed character when the page reads its own URL writes", () => {
|
|
360
|
+
window.history.replaceState(null, "", "/device");
|
|
361
|
+
const api = deviceApi();
|
|
362
|
+
const page = mount(() => {
|
|
363
|
+
const device = useDeviceApproval(api, window.location.search, SCOPE);
|
|
364
|
+
return { device, field: useDeviceCodeField(device) };
|
|
365
|
+
});
|
|
366
|
+
for (const code of ["B", "BC", "BCD", "BCDF"]) {
|
|
367
|
+
act(() => page.current.field.inputProps.onChange(changeTo(code)));
|
|
368
|
+
expect(page.current.device.code).toBe(code);
|
|
369
|
+
expect(new URLSearchParams(window.location.search).get("code")).toBe(code);
|
|
370
|
+
}
|
|
371
|
+
window.history.replaceState(null, "", "/device?code=WDJBMJHT");
|
|
372
|
+
page.render();
|
|
373
|
+
expect(page.current.device.code).toBe("WDJB-MJHT");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it.each(["approve", "deny"] as const)(
|
|
377
|
+
"keeps the terminal %s result when clearing its own URL, but accepts an external code",
|
|
378
|
+
async (decision) => {
|
|
379
|
+
window.history.replaceState(null, "", "/device?code=WDJBMJHT");
|
|
380
|
+
const api = deviceApi();
|
|
381
|
+
const page = mount(() => {
|
|
382
|
+
const device = useDeviceApproval(api, window.location.search, SCOPE);
|
|
383
|
+
return { device, field: useDeviceCodeField(device) };
|
|
384
|
+
});
|
|
385
|
+
await settle();
|
|
386
|
+
act(() => page.current.device[decision]());
|
|
387
|
+
await settle();
|
|
388
|
+
page.render();
|
|
389
|
+
expect(window.location.search).toBe("");
|
|
390
|
+
expect(page.current.device.state.status).toBe(decision === "approve" ? "approved" : "denied");
|
|
391
|
+
expect(page.current.device.code).toBe("WDJB-MJHT");
|
|
392
|
+
window.history.replaceState(null, "", "/device?code=BCDFGHJK");
|
|
393
|
+
page.render();
|
|
394
|
+
await settle();
|
|
395
|
+
expect(page.current.device.code).toBe("BCDF-GHJK");
|
|
396
|
+
expect(page.current.device.state.status).toBe("ready");
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
|
|
296
400
|
it("wires the field to its status line and keeps the code in the address until decided", async () => {
|
|
297
401
|
window.history.replaceState({ idx: 3 }, "", "/device?from=mail&code=wdjbmjht");
|
|
298
402
|
const api = deviceApi();
|
|
299
403
|
const page = mount(() => {
|
|
300
|
-
const device = useDeviceApproval(api, window.location.search);
|
|
404
|
+
const device = useDeviceApproval(api, window.location.search, SCOPE);
|
|
301
405
|
return { device, field: useDeviceCodeField(device) };
|
|
302
406
|
});
|
|
303
407
|
await settle();
|
|
@@ -330,7 +434,7 @@ describe("useDeviceCodeField", () => {
|
|
|
330
434
|
|
|
331
435
|
it("focuses the field only when it starts empty", () => {
|
|
332
436
|
window.history.replaceState(null, "", "/device");
|
|
333
|
-
const page = mount(() => useDeviceCodeField(useDeviceApproval(deviceApi(), "")));
|
|
437
|
+
const page = mount(() => useDeviceCodeField(useDeviceApproval(deviceApi(), "", SCOPE)));
|
|
334
438
|
expect(page.current.inputProps.autoFocus).toBe(true);
|
|
335
439
|
expect(window.location.search).toBe("");
|
|
336
440
|
});
|
|
@@ -401,8 +505,8 @@ describe("createOAuthConsoleApi", () => {
|
|
|
401
505
|
: { ...DEVICE, accountEmail: "ana@example.com" },
|
|
402
506
|
);
|
|
403
507
|
const api = createOAuthConsoleApi<string, Workspaces, Account>(http, "/auth/oauth");
|
|
404
|
-
const consent = mount(() => useConsentRequest(api.consent, "?client_id=x"));
|
|
405
|
-
const device = mount(() => useDeviceApproval(api.device, "?code=WDJB-MJHT"));
|
|
508
|
+
const consent = mount(() => useConsentRequest(api.consent, "?client_id=x", SCOPE));
|
|
509
|
+
const device = mount(() => useDeviceApproval(api.device, "?code=WDJB-MJHT", SCOPE));
|
|
406
510
|
await settle();
|
|
407
511
|
const consentState = consent.current.state;
|
|
408
512
|
const deviceState = device.current.state;
|
|
@@ -417,6 +521,602 @@ describe("createOAuthConsoleApi", () => {
|
|
|
417
521
|
// @ts-expect-error: the consent page's fields are not the device page's.
|
|
418
522
|
expect(deviceState.view.workspaces).toBeUndefined();
|
|
419
523
|
});
|
|
524
|
+
|
|
525
|
+
it("forwards query cancellation through both describe endpoints to fetch", async () => {
|
|
526
|
+
const { http } = client();
|
|
527
|
+
const signals: AbortSignal[] = [];
|
|
528
|
+
vi.mocked(fetch).mockImplementation((_url, options) => {
|
|
529
|
+
const signal = options?.signal;
|
|
530
|
+
if (!signal) throw new Error("Missing query cancellation signal");
|
|
531
|
+
signals.push(signal);
|
|
532
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
533
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
const api = createOAuthConsoleApi(http, "/auth/oauth");
|
|
537
|
+
const page = mount(() => ({
|
|
538
|
+
consent: useConsentRequest(api.consent, "?client_id=x", SCOPE),
|
|
539
|
+
device: useDeviceApproval(api.device, "?code=WDJBMJHT", SCOPE),
|
|
540
|
+
}));
|
|
541
|
+
await settle();
|
|
542
|
+
expect(signals).toHaveLength(2);
|
|
543
|
+
expect(signals.every((signal) => !signal.aborted)).toBe(true);
|
|
544
|
+
await act(async () => page.client.cancelQueries());
|
|
545
|
+
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("aborts both decision transports and rejects completed responses after an identity boundary", async () => {
|
|
549
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
550
|
+
const { http } = client();
|
|
551
|
+
const consentAnswer = deferred<Response>();
|
|
552
|
+
const deviceAnswer = deferred<Response>();
|
|
553
|
+
const signals: AbortSignal[] = [];
|
|
554
|
+
vi.mocked(fetch).mockImplementation(async (url, options) => {
|
|
555
|
+
if (options?.method === "POST") {
|
|
556
|
+
if (!options.signal) throw new Error("Missing decision cancellation signal");
|
|
557
|
+
signals.push(options.signal);
|
|
558
|
+
// A completed server response may still arrive after transport cancellation.
|
|
559
|
+
return String(url).endsWith("/approve") ? consentAnswer.promise : deviceAnswer.promise;
|
|
560
|
+
}
|
|
561
|
+
return new Response(
|
|
562
|
+
JSON.stringify({ data: String(url).includes("/request") ? VIEW : DEVICE }),
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
const boundary = identityBoundary();
|
|
566
|
+
let scope = boundary.capture();
|
|
567
|
+
const api = createOAuthConsoleApi(http, "/auth/oauth");
|
|
568
|
+
const page = mount(() => ({
|
|
569
|
+
consent: useConsentRequest(api.consent, "?client_id=x", scope),
|
|
570
|
+
device: useDeviceApproval(api.device, "?code=WDJBMJHT", scope),
|
|
571
|
+
}));
|
|
572
|
+
await settle();
|
|
573
|
+
act(() => {
|
|
574
|
+
page.current.consent.approve();
|
|
575
|
+
page.current.device.deny();
|
|
576
|
+
});
|
|
577
|
+
await settle();
|
|
578
|
+
const mutations = page.client.getMutationCache().getAll();
|
|
579
|
+
boundary.change();
|
|
580
|
+
scope = boundary.capture("account-b");
|
|
581
|
+
act(() => page.client.clear());
|
|
582
|
+
page.render();
|
|
583
|
+
expect(signals).toHaveLength(2);
|
|
584
|
+
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
|
585
|
+
consentAnswer.resolve(
|
|
586
|
+
new Response(
|
|
587
|
+
JSON.stringify({ data: { ok: true, redirectTo: "https://app.test/cb?code=old" } }),
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
deviceAnswer.resolve(new Response(JSON.stringify({ data: { ok: true } })));
|
|
591
|
+
await settle();
|
|
592
|
+
expect(assign).not.toHaveBeenCalled();
|
|
593
|
+
for (const mutation of mutations) {
|
|
594
|
+
expect(mutation.state.status).toBe("error");
|
|
595
|
+
expect(mutation.state.data).toBeUndefined();
|
|
596
|
+
expect(mutation.state.error).toBeInstanceOf(OAuthOperationChangedError);
|
|
597
|
+
}
|
|
598
|
+
expect(page.current.device.state.status).toBe("ready");
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe("OAuth scope and operation lifetime", () => {
|
|
603
|
+
it.each(["input", "scope"] as const)(
|
|
604
|
+
"does not reuse a previous approval target or its extra fields after %s changes",
|
|
605
|
+
async (change) => {
|
|
606
|
+
type Extra = { workspaces: string[] };
|
|
607
|
+
const client = new QueryClient({
|
|
608
|
+
defaultOptions: { queries: { placeholderData: keepPreviousData } },
|
|
609
|
+
});
|
|
610
|
+
const nextConsent = deferred<ConsentView<Extra>>();
|
|
611
|
+
const nextDevice = deferred<DeviceView<Extra>>();
|
|
612
|
+
let changed = false;
|
|
613
|
+
let scope = SCOPE;
|
|
614
|
+
let search = "?client_id=x";
|
|
615
|
+
let code = "?code=WDJBMJHT";
|
|
616
|
+
const oldConsent = { ...VIEW, workspaces: ["old-workspace"] };
|
|
617
|
+
const oldDevice = { ...DEVICE, workspaces: ["old-workspace"] };
|
|
618
|
+
const consent: ConsentApi<void, Extra> = {
|
|
619
|
+
describe: () => (changed ? nextConsent.promise : Promise.resolve(oldConsent)),
|
|
620
|
+
approve: vi.fn(),
|
|
621
|
+
};
|
|
622
|
+
const device: DeviceApi<void, Extra> = {
|
|
623
|
+
describe: () => (changed ? nextDevice.promise : Promise.resolve(oldDevice)),
|
|
624
|
+
decide: vi.fn(),
|
|
625
|
+
};
|
|
626
|
+
const page = mount(
|
|
627
|
+
() => ({
|
|
628
|
+
consent: useConsentRequest(consent, search, scope),
|
|
629
|
+
device: useDeviceApproval(device, code, scope),
|
|
630
|
+
}),
|
|
631
|
+
client,
|
|
632
|
+
);
|
|
633
|
+
await settle();
|
|
634
|
+
expect(page.current.consent.state).toEqual({ status: "ready", view: oldConsent });
|
|
635
|
+
expect(page.current.device.state).toEqual({ status: "ready", view: oldDevice });
|
|
636
|
+
changed = true;
|
|
637
|
+
if (change === "scope") scope = { key: ["account-b", 1], isCurrent: () => true };
|
|
638
|
+
else {
|
|
639
|
+
search = "?client_id=y";
|
|
640
|
+
code = "?code=BCDFGHJK";
|
|
641
|
+
}
|
|
642
|
+
page.render();
|
|
643
|
+
expect(page.current.consent.state).toEqual({ status: "loading" });
|
|
644
|
+
expect(page.current.device.state).toEqual({ status: "loading" });
|
|
645
|
+
const newConsent = { ...VIEW, clientName: "New app", workspaces: ["new-workspace"] };
|
|
646
|
+
const newDevice = { ...DEVICE, clientName: "New script", workspaces: ["new-workspace"] };
|
|
647
|
+
nextConsent.resolve(newConsent);
|
|
648
|
+
nextDevice.resolve(newDevice);
|
|
649
|
+
await settle();
|
|
650
|
+
expect(page.current.consent.state).toEqual({ status: "ready", view: newConsent });
|
|
651
|
+
expect(page.current.device.state).toEqual({ status: "ready", view: newDevice });
|
|
652
|
+
},
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
it("invalidates the previous request by the layout commit, before passive cleanup", async () => {
|
|
656
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
657
|
+
const answer = deferred<{ ok: true; redirectTo: string }>();
|
|
658
|
+
let search = "?client_id=x";
|
|
659
|
+
let signal: AbortSignal | undefined;
|
|
660
|
+
let abortedAtCommit = false;
|
|
661
|
+
const api: ConsentApi = {
|
|
662
|
+
describe: async () => VIEW,
|
|
663
|
+
approve: (_query, _choice, options) => {
|
|
664
|
+
signal = options?.signal ?? undefined;
|
|
665
|
+
return answer.promise;
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
const page = mount(() => {
|
|
669
|
+
const requestSearch = search;
|
|
670
|
+
const consent = useConsentRequest(api, requestSearch, SCOPE);
|
|
671
|
+
useLayoutEffect(() => {
|
|
672
|
+
if (requestSearch === "?client_id=y") {
|
|
673
|
+
abortedAtCommit = signal?.aborted === true;
|
|
674
|
+
answer.resolve({ ok: true, redirectTo: "https://app.test/cb?code=old" });
|
|
675
|
+
}
|
|
676
|
+
}, [requestSearch]);
|
|
677
|
+
return consent;
|
|
678
|
+
});
|
|
679
|
+
await settle();
|
|
680
|
+
act(() => page.current.approve());
|
|
681
|
+
await settle();
|
|
682
|
+
search = "?client_id=y";
|
|
683
|
+
page.render();
|
|
684
|
+
await settle();
|
|
685
|
+
expect(abortedAtCommit).toBe(true);
|
|
686
|
+
expect(assign).not.toHaveBeenCalled();
|
|
687
|
+
expect(page.client.getMutationCache().getAll()[0]?.state).toMatchObject({
|
|
688
|
+
status: "error",
|
|
689
|
+
data: undefined,
|
|
690
|
+
error: expect.any(OAuthOperationChangedError),
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it.each(["approve", "deny"] as const)(
|
|
695
|
+
"rejects stale consent and device %s after an awaited global settlement",
|
|
696
|
+
async (decision) => {
|
|
697
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
698
|
+
const boundary = identityBoundary();
|
|
699
|
+
const scope = boundary.capture();
|
|
700
|
+
const settlement = deferred<void>();
|
|
701
|
+
const settled = vi.fn((_data: unknown, error: unknown) =>
|
|
702
|
+
error === null ? settlement.promise : undefined,
|
|
703
|
+
);
|
|
704
|
+
const client = new QueryClient({ mutationCache: new MutationCache({ onSettled: settled }) });
|
|
705
|
+
const page = mount(
|
|
706
|
+
() => ({
|
|
707
|
+
consent: useConsentRequest(
|
|
708
|
+
{
|
|
709
|
+
describe: async () => VIEW,
|
|
710
|
+
approve: async () => ({ ok: true, redirectTo: "https://app.test/cb" }),
|
|
711
|
+
},
|
|
712
|
+
"?client_id=x",
|
|
713
|
+
scope,
|
|
714
|
+
),
|
|
715
|
+
device: useDeviceApproval(deviceApi(), "?code=WDJBMJHT", scope),
|
|
716
|
+
}),
|
|
717
|
+
client,
|
|
718
|
+
);
|
|
719
|
+
await settle();
|
|
720
|
+
act(() => {
|
|
721
|
+
page.current.consent.approve();
|
|
722
|
+
page.current.device[decision]();
|
|
723
|
+
});
|
|
724
|
+
await settle();
|
|
725
|
+
expect(settled).toHaveBeenCalledTimes(2);
|
|
726
|
+
expect(
|
|
727
|
+
client
|
|
728
|
+
.getMutationCache()
|
|
729
|
+
.getAll()
|
|
730
|
+
.every((mutation) => mutation.state.status === "pending"),
|
|
731
|
+
).toBe(true);
|
|
732
|
+
expect(assign).not.toHaveBeenCalled();
|
|
733
|
+
boundary.change();
|
|
734
|
+
settlement.resolve();
|
|
735
|
+
await settle();
|
|
736
|
+
for (const mutation of client.getMutationCache().getAll()) {
|
|
737
|
+
expect(mutation.state).toMatchObject({
|
|
738
|
+
status: "error",
|
|
739
|
+
data: undefined,
|
|
740
|
+
error: expect.any(OAuthOperationChangedError),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
expect(assign).not.toHaveBeenCalled();
|
|
744
|
+
expect(page.current.device.state.status).not.toBe("approved");
|
|
745
|
+
expect(page.current.device.state.status).not.toBe("denied");
|
|
746
|
+
},
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
it("isolates identical requests and codes across scopes in one query client", async () => {
|
|
750
|
+
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } });
|
|
751
|
+
const describeConsent = vi.fn(async (account: string) => ({ ...VIEW, clientName: account }));
|
|
752
|
+
const describeDevice = vi.fn(async (account: string) => ({ ...DEVICE, clientName: account }));
|
|
753
|
+
const page = mount(() => {
|
|
754
|
+
const a = { key: ["a", 0], isCurrent: () => true };
|
|
755
|
+
const b = { key: ["b", 0], isCurrent: () => true };
|
|
756
|
+
return {
|
|
757
|
+
a: useConsentRequest({ describe: () => describeConsent("a"), approve: vi.fn() }, "?x=1", a),
|
|
758
|
+
b: useConsentRequest({ describe: () => describeConsent("b"), approve: vi.fn() }, "x=1", b),
|
|
759
|
+
normalized: useConsentRequest(
|
|
760
|
+
{ describe: () => describeConsent("duplicate"), approve: vi.fn() },
|
|
761
|
+
"x=1",
|
|
762
|
+
a,
|
|
763
|
+
),
|
|
764
|
+
deviceA: useDeviceApproval(
|
|
765
|
+
{ describe: () => describeDevice("a"), decide: vi.fn() },
|
|
766
|
+
"?code=wdjb-mjht",
|
|
767
|
+
a,
|
|
768
|
+
),
|
|
769
|
+
deviceB: useDeviceApproval(
|
|
770
|
+
{ describe: () => describeDevice("b"), decide: vi.fn() },
|
|
771
|
+
"?code=WDJBMJHT",
|
|
772
|
+
b,
|
|
773
|
+
),
|
|
774
|
+
normalizedDevice: useDeviceApproval(
|
|
775
|
+
{ describe: () => describeDevice("duplicate"), decide: vi.fn() },
|
|
776
|
+
"?code=WDJBMJHT",
|
|
777
|
+
a,
|
|
778
|
+
),
|
|
779
|
+
};
|
|
780
|
+
}, client);
|
|
781
|
+
await settle();
|
|
782
|
+
expect(page.current.a.state).toEqual({ status: "ready", view: { ...VIEW, clientName: "a" } });
|
|
783
|
+
expect(page.current.b.state).toEqual({ status: "ready", view: { ...VIEW, clientName: "b" } });
|
|
784
|
+
expect(page.current.normalized.state).toEqual(page.current.a.state);
|
|
785
|
+
expect(page.current.deviceA.state).toEqual({
|
|
786
|
+
status: "ready",
|
|
787
|
+
view: { ...DEVICE, clientName: "a" },
|
|
788
|
+
});
|
|
789
|
+
expect(page.current.deviceB.state).toEqual({
|
|
790
|
+
status: "ready",
|
|
791
|
+
view: { ...DEVICE, clientName: "b" },
|
|
792
|
+
});
|
|
793
|
+
expect(page.current.normalizedDevice.state).toEqual(page.current.deviceA.state);
|
|
794
|
+
expect(describeConsent).toHaveBeenCalledTimes(2);
|
|
795
|
+
expect(describeDevice).toHaveBeenCalledTimes(2);
|
|
796
|
+
expect(
|
|
797
|
+
client
|
|
798
|
+
.getQueryCache()
|
|
799
|
+
.getAll()
|
|
800
|
+
.map((query) => query.queryKey),
|
|
801
|
+
).toEqual([
|
|
802
|
+
["a", 0, "@gusnips/react/oauth", "consent", "x=1"],
|
|
803
|
+
["b", 0, "@gusnips/react/oauth", "consent", "x=1"],
|
|
804
|
+
["a", 0, "@gusnips/react/oauth", "device", "WDJBMJHT"],
|
|
805
|
+
["b", 0, "@gusnips/react/oauth", "device", "WDJBMJHT"],
|
|
806
|
+
]);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it.each(["approve", "deny"] as const)(
|
|
810
|
+
"rejects delayed consent and device %s after scope change and cache clear",
|
|
811
|
+
async (pressed) => {
|
|
812
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
813
|
+
const boundary = identityBoundary();
|
|
814
|
+
let scope = boundary.capture();
|
|
815
|
+
const consentAnswer = deferred<{ ok: true; redirectTo: string }>();
|
|
816
|
+
const deviceAnswer = deferred<{ ok: true }>();
|
|
817
|
+
const signals: AbortSignal[] = [];
|
|
818
|
+
const consentApi: ConsentApi = {
|
|
819
|
+
describe: async () => VIEW,
|
|
820
|
+
approve: (_query, _choice, options) => {
|
|
821
|
+
if (options?.signal) signals.push(options.signal);
|
|
822
|
+
return consentAnswer.promise; // Deliberately ignores cancellation: the server may have finished.
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
const api = deviceApi({
|
|
826
|
+
decide: (_code, _pressed, _choice, options) => {
|
|
827
|
+
if (options?.signal) signals.push(options.signal);
|
|
828
|
+
return deviceAnswer.promise;
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
const page = mount(() => ({
|
|
832
|
+
consent: useConsentRequest(consentApi, "?client_id=x", scope),
|
|
833
|
+
device: useDeviceApproval(api, "?code=WDJBMJHT", scope),
|
|
834
|
+
}));
|
|
835
|
+
await settle();
|
|
836
|
+
act(() => {
|
|
837
|
+
page.current.consent.approve();
|
|
838
|
+
page.current.device[pressed]();
|
|
839
|
+
});
|
|
840
|
+
await settle();
|
|
841
|
+
const oldMutations = page.client.getMutationCache().getAll();
|
|
842
|
+
expect(oldMutations).toHaveLength(2);
|
|
843
|
+
boundary.change();
|
|
844
|
+
scope = boundary.capture("account-b");
|
|
845
|
+
act(() => page.client.clear());
|
|
846
|
+
page.render();
|
|
847
|
+
expect(page.current.consent.leaving).toBe(false);
|
|
848
|
+
expect(page.current.device.deciding).toBe(false);
|
|
849
|
+
expect(signals).toHaveLength(2);
|
|
850
|
+
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
|
851
|
+
consentAnswer.resolve({ ok: true, redirectTo: "https://app.test/cb?code=old" });
|
|
852
|
+
deviceAnswer.resolve({ ok: true });
|
|
853
|
+
await settle();
|
|
854
|
+
expect(assign).not.toHaveBeenCalled();
|
|
855
|
+
for (const mutation of oldMutations) {
|
|
856
|
+
expect(mutation.state.status).toBe("error");
|
|
857
|
+
expect(mutation.state.error).toBeInstanceOf(OAuthOperationChangedError);
|
|
858
|
+
expect(mutation.state.data).toBeUndefined();
|
|
859
|
+
}
|
|
860
|
+
expect(page.current.consent.leaving).toBe(false);
|
|
861
|
+
expect(page.current.device.state.status).toBe("ready");
|
|
862
|
+
},
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
it("rejects an old approval after the same account returns, even before React sees the boundary", async () => {
|
|
866
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
867
|
+
const boundary = identityBoundary();
|
|
868
|
+
const scope = boundary.capture();
|
|
869
|
+
const answer = deferred<{ ok: true; redirectTo: string }>();
|
|
870
|
+
const page = mount(() =>
|
|
871
|
+
useConsentRequest(
|
|
872
|
+
{ describe: async () => VIEW, approve: () => answer.promise },
|
|
873
|
+
"?x=1",
|
|
874
|
+
scope,
|
|
875
|
+
),
|
|
876
|
+
);
|
|
877
|
+
await settle();
|
|
878
|
+
act(() => page.current.approve());
|
|
879
|
+
await settle();
|
|
880
|
+
boundary.change(); // A -> B
|
|
881
|
+
boundary.change(); // B -> A, with the same account id but a new generation.
|
|
882
|
+
expect(boundary.capture().key[0]).toBe(scope.key[0]);
|
|
883
|
+
answer.resolve({ ok: true, redirectTo: "https://app.test/cb?code=old" });
|
|
884
|
+
await settle();
|
|
885
|
+
expect(assign).not.toHaveBeenCalled();
|
|
886
|
+
const mutation = page.client.getMutationCache().getAll()[0];
|
|
887
|
+
expect(mutation?.state.status).toBe("error");
|
|
888
|
+
expect(mutation?.state.data).toBeUndefined();
|
|
889
|
+
expect(mutation?.state.error).toBeInstanceOf(OAuthOperationChangedError);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("checks identity again after mutation-cache callbacks and before redirecting", async () => {
|
|
893
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
894
|
+
const boundary = identityBoundary();
|
|
895
|
+
const scope = boundary.capture();
|
|
896
|
+
const client = new QueryClient({
|
|
897
|
+
mutationCache: new MutationCache({ onSuccess: () => boundary.change() }),
|
|
898
|
+
});
|
|
899
|
+
const page = mount(
|
|
900
|
+
() =>
|
|
901
|
+
useConsentRequest(
|
|
902
|
+
{
|
|
903
|
+
describe: async () => VIEW,
|
|
904
|
+
approve: async () => ({ ok: true, redirectTo: "https://app.test/cb" }),
|
|
905
|
+
},
|
|
906
|
+
"?x=1",
|
|
907
|
+
scope,
|
|
908
|
+
),
|
|
909
|
+
client,
|
|
910
|
+
);
|
|
911
|
+
await settle();
|
|
912
|
+
act(() => page.current.approve());
|
|
913
|
+
await settle();
|
|
914
|
+
expect(assign).not.toHaveBeenCalled();
|
|
915
|
+
expect(client.getMutationCache().getAll()[0]?.state).toMatchObject({
|
|
916
|
+
status: "error",
|
|
917
|
+
data: undefined,
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it.each(["request", "code", "link", "unmount"] as const)(
|
|
922
|
+
"invalidates delayed decisions after %s changes",
|
|
923
|
+
async (change) => {
|
|
924
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
925
|
+
const consentAnswer = deferred<{ ok: true; redirectTo: string }>();
|
|
926
|
+
const deviceAnswer = deferred<{ ok: true }>();
|
|
927
|
+
const signals: AbortSignal[] = [];
|
|
928
|
+
const consentApi: ConsentApi = {
|
|
929
|
+
describe: async () => VIEW,
|
|
930
|
+
approve: (_query, _choice, options) => {
|
|
931
|
+
if (options?.signal) signals.push(options.signal);
|
|
932
|
+
return consentAnswer.promise;
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
const api = deviceApi({
|
|
936
|
+
decide: (_code, _decision, _choice, options) => {
|
|
937
|
+
if (options?.signal) signals.push(options.signal);
|
|
938
|
+
return deviceAnswer.promise;
|
|
939
|
+
},
|
|
940
|
+
});
|
|
941
|
+
let search = "?client_id=x";
|
|
942
|
+
let deviceSearch = "?code=WDJBMJHT";
|
|
943
|
+
const page = mount(() => ({
|
|
944
|
+
consent: useConsentRequest(consentApi, search, SCOPE),
|
|
945
|
+
device: useDeviceApproval(api, deviceSearch, SCOPE),
|
|
946
|
+
}));
|
|
947
|
+
await settle();
|
|
948
|
+
act(() => {
|
|
949
|
+
page.current.consent.approve();
|
|
950
|
+
page.current.device.deny();
|
|
951
|
+
});
|
|
952
|
+
await settle();
|
|
953
|
+
if (change === "unmount") page.unmount();
|
|
954
|
+
else if (change === "request") {
|
|
955
|
+
search = "?client_id=y";
|
|
956
|
+
page.render();
|
|
957
|
+
} else if (change === "link") {
|
|
958
|
+
deviceSearch = "?code=BCDFGHJK";
|
|
959
|
+
page.render();
|
|
960
|
+
} else act(() => page.current.device.setCode("BCDFGHJK"));
|
|
961
|
+
consentAnswer.resolve({ ok: true, redirectTo: "https://app.test/cb?code=old" });
|
|
962
|
+
deviceAnswer.resolve({ ok: true });
|
|
963
|
+
await settle();
|
|
964
|
+
const mutations = page.client.getMutationCache().getAll();
|
|
965
|
+
const stale =
|
|
966
|
+
change === "request" ? [mutations[0]] : change === "unmount" ? mutations : [mutations[1]];
|
|
967
|
+
for (const mutation of stale) {
|
|
968
|
+
expect(mutation?.state.status).toBe("error");
|
|
969
|
+
expect(mutation?.state.error).toBeInstanceOf(OAuthOperationChangedError);
|
|
970
|
+
expect(mutation?.state.data).toBeUndefined();
|
|
971
|
+
}
|
|
972
|
+
expect(signals[change === "request" ? 0 : 1]?.aborted).toBe(true);
|
|
973
|
+
if (change === "request" || change === "unmount") expect(assign).not.toHaveBeenCalled();
|
|
974
|
+
else {
|
|
975
|
+
expect(page.current.device.state.status).toBe("ready");
|
|
976
|
+
expect(page.current.device.code).toBe("BCDF-GHJK");
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
it("keeps fresh controllers after StrictMode cleanup and accepts new decisions", async () => {
|
|
982
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
983
|
+
const signals: AbortSignal[] = [];
|
|
984
|
+
const consentApi: ConsentApi = {
|
|
985
|
+
describe: async () => VIEW,
|
|
986
|
+
approve: async (_query, _choice, options) => {
|
|
987
|
+
if (options?.signal) signals.push(options.signal);
|
|
988
|
+
return { ok: true, redirectTo: "https://app.test/cb" };
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
const api = deviceApi({
|
|
992
|
+
decide: async (_code, _decision, _choice, options) => {
|
|
993
|
+
if (options?.signal) signals.push(options.signal);
|
|
994
|
+
return { ok: true };
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
const page = mount(
|
|
998
|
+
() => ({
|
|
999
|
+
consent: useConsentRequest(consentApi, "?x=1", SCOPE),
|
|
1000
|
+
device: useDeviceApproval(api, "?code=WDJBMJHT", SCOPE),
|
|
1001
|
+
}),
|
|
1002
|
+
undefined,
|
|
1003
|
+
true,
|
|
1004
|
+
);
|
|
1005
|
+
await settle();
|
|
1006
|
+
act(() => {
|
|
1007
|
+
page.current.consent.approve();
|
|
1008
|
+
page.current.device.approve();
|
|
1009
|
+
});
|
|
1010
|
+
await settle();
|
|
1011
|
+
expect(signals).toHaveLength(2);
|
|
1012
|
+
expect(signals.every((signal) => !signal.aborted)).toBe(true);
|
|
1013
|
+
expect(assign).toHaveBeenCalledOnce();
|
|
1014
|
+
expect(page.current.device.state.status).toBe("approved");
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("does not retry or refresh on focus despite aggressive defaults, but allows explicit retries", async () => {
|
|
1018
|
+
const client = new QueryClient({
|
|
1019
|
+
defaultOptions: {
|
|
1020
|
+
queries: { retry: 5, retryDelay: 0, refetchOnWindowFocus: "always" },
|
|
1021
|
+
mutations: { retry: 5, retryDelay: 0 },
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
let fail = true;
|
|
1025
|
+
const describeConsent = vi.fn(async () => {
|
|
1026
|
+
if (fail) throw new TypeError("Failed to fetch");
|
|
1027
|
+
return VIEW;
|
|
1028
|
+
});
|
|
1029
|
+
const describeDevice = vi.fn(async () => {
|
|
1030
|
+
if (fail) throw new TypeError("Failed to fetch");
|
|
1031
|
+
return DEVICE;
|
|
1032
|
+
});
|
|
1033
|
+
const approve = vi.fn(async () => {
|
|
1034
|
+
throw new TypeError("Failed to fetch");
|
|
1035
|
+
});
|
|
1036
|
+
const decide = vi.fn(async () => {
|
|
1037
|
+
throw new TypeError("Failed to fetch");
|
|
1038
|
+
});
|
|
1039
|
+
const page = mount(
|
|
1040
|
+
() => ({
|
|
1041
|
+
consent: useConsentRequest({ describe: describeConsent, approve }, "?x=1", SCOPE),
|
|
1042
|
+
device: useDeviceApproval({ describe: describeDevice, decide }, "?code=WDJBMJHT", SCOPE),
|
|
1043
|
+
}),
|
|
1044
|
+
client,
|
|
1045
|
+
);
|
|
1046
|
+
await settle();
|
|
1047
|
+
expect(describeConsent).toHaveBeenCalledTimes(1);
|
|
1048
|
+
expect(describeDevice).toHaveBeenCalledTimes(1);
|
|
1049
|
+
act(() => {
|
|
1050
|
+
focusManager.setFocused(false);
|
|
1051
|
+
focusManager.setFocused(true);
|
|
1052
|
+
});
|
|
1053
|
+
await settle();
|
|
1054
|
+
expect(describeConsent).toHaveBeenCalledTimes(1);
|
|
1055
|
+
expect(describeDevice).toHaveBeenCalledTimes(1);
|
|
1056
|
+
fail = false;
|
|
1057
|
+
act(() => {
|
|
1058
|
+
if (
|
|
1059
|
+
page.current.consent.state.status !== "failed" ||
|
|
1060
|
+
page.current.device.state.status !== "failed"
|
|
1061
|
+
)
|
|
1062
|
+
throw new Error("Expected two failures");
|
|
1063
|
+
page.current.consent.state.retry();
|
|
1064
|
+
page.current.device.state.retry();
|
|
1065
|
+
});
|
|
1066
|
+
await settle();
|
|
1067
|
+
expect(page.current.consent.state.status).toBe("ready");
|
|
1068
|
+
expect(page.current.device.state.status).toBe("ready");
|
|
1069
|
+
act(() => {
|
|
1070
|
+
page.current.consent.approve();
|
|
1071
|
+
page.current.device.deny();
|
|
1072
|
+
});
|
|
1073
|
+
await settle();
|
|
1074
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
1075
|
+
expect(decide).toHaveBeenCalledTimes(1);
|
|
1076
|
+
expect(page.current.consent.approveError).toBeInstanceOf(TypeError);
|
|
1077
|
+
expect(page.current.device.decideError).toBeInstanceOf(TypeError);
|
|
1078
|
+
act(() => {
|
|
1079
|
+
page.current.consent.approve();
|
|
1080
|
+
page.current.device.deny();
|
|
1081
|
+
});
|
|
1082
|
+
await settle();
|
|
1083
|
+
expect(approve).toHaveBeenCalledTimes(2);
|
|
1084
|
+
expect(decide).toHaveBeenCalledTimes(2);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it("does not queue decisions while offline", async () => {
|
|
1088
|
+
const approve = vi.fn(async () => {
|
|
1089
|
+
throw new TypeError("Failed to fetch");
|
|
1090
|
+
});
|
|
1091
|
+
const api = deviceApi({
|
|
1092
|
+
decide: async () => {
|
|
1093
|
+
throw new TypeError("Failed to fetch");
|
|
1094
|
+
},
|
|
1095
|
+
});
|
|
1096
|
+
const page = mount(() => ({
|
|
1097
|
+
consent: useConsentRequest({ describe: async () => VIEW, approve }, "?x=1", SCOPE),
|
|
1098
|
+
device: useDeviceApproval(api, "?code=WDJBMJHT", SCOPE),
|
|
1099
|
+
}));
|
|
1100
|
+
await settle();
|
|
1101
|
+
onlineManager.setOnline(false);
|
|
1102
|
+
act(() => {
|
|
1103
|
+
page.current.consent.approve();
|
|
1104
|
+
page.current.device.deny();
|
|
1105
|
+
});
|
|
1106
|
+
await settle();
|
|
1107
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
1108
|
+
expect(api.decide).toHaveBeenCalledTimes(1);
|
|
1109
|
+
expect(
|
|
1110
|
+
page.client
|
|
1111
|
+
.getMutationCache()
|
|
1112
|
+
.getAll()
|
|
1113
|
+
.every((mutation) => mutation.state.status === "error" && !mutation.state.isPaused),
|
|
1114
|
+
).toBe(true);
|
|
1115
|
+
onlineManager.setOnline(true);
|
|
1116
|
+
await settle();
|
|
1117
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
1118
|
+
expect(api.decide).toHaveBeenCalledTimes(1);
|
|
1119
|
+
});
|
|
420
1120
|
});
|
|
421
1121
|
|
|
422
1122
|
describe("formatUserCode", () => {
|