@gusnips/react 0.9.32 → 0.10.0

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/src/oauth.test.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  // @vitest-environment happy-dom
2
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
- import { act, createElement, type ChangeEvent } from "react";
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
- /** Renders `hook` under a fresh query client and keeps its latest answer in `current`. */
30
- function mount<T>(hook: () => T) {
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
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
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
- act(() => root.render(createElement(QueryClientProvider, { client }, createElement(Probe))));
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", () => {