@gusnips/react 0.9.28 → 0.9.30
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 +123 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +58 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +94 -0
- package/dist/keys.js.map +1 -0
- package/dist/oauth.d.ts +145 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +99 -0
- package/dist/oauth.js.map +1 -0
- package/package.json +9 -1
- package/src/index.ts +1 -0
- package/src/keys.test.ts +253 -0
- package/src/keys.ts +144 -0
- package/src/oauth.test.ts +257 -0
- package/src/oauth.ts +217 -0
- package/src/supabase-session.browser.test.ts +6 -1
package/src/keys.test.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { act, createElement, useLayoutEffect } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
createKeyLifecycle,
|
|
9
|
+
KeyScopeChangedError,
|
|
10
|
+
useKeyReveals,
|
|
11
|
+
type KeyLifecycle,
|
|
12
|
+
type RevealedKey,
|
|
13
|
+
} from "./keys.ts";
|
|
14
|
+
|
|
15
|
+
interface KeyRecord {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
prefix: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
22
|
+
|
|
23
|
+
const cleanups: (() => void)[] = [];
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
26
|
+
document.body.replaceChildren();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function minted(id: string): RevealedKey<KeyRecord> {
|
|
30
|
+
const key = `sk_live_${id}_${"x".repeat(24)}`;
|
|
31
|
+
return { record: { id, name: `key ${id}`, prefix: key.slice(0, 10) }, key };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A request the test answers by hand, to hold it in flight across a navigation. */
|
|
35
|
+
function deferred<T>() {
|
|
36
|
+
let resolve!: (value: T) => void;
|
|
37
|
+
const promise = new Promise<T>((yes) => {
|
|
38
|
+
resolve = yes;
|
|
39
|
+
});
|
|
40
|
+
return { promise, resolve };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lifecycleIn(scope: string | null = "account-1:workspace-1") {
|
|
44
|
+
const keys = createKeyLifecycle<KeyRecord>();
|
|
45
|
+
keys.setScope(scope);
|
|
46
|
+
// Each test leaves the window as it found it: no unread secrets means no unload listener.
|
|
47
|
+
cleanups.push(() => keys.setScope(null));
|
|
48
|
+
return keys;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The prompt shows only when a listener cancels the event. */
|
|
52
|
+
function closingPrompts(): boolean {
|
|
53
|
+
const event = new Event("beforeunload", { cancelable: true });
|
|
54
|
+
window.dispatchEvent(event);
|
|
55
|
+
return event.defaultPrevented;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("createKeyLifecycle", () => {
|
|
59
|
+
it("keeps every unread secret, newest first, and resolves only the record", async () => {
|
|
60
|
+
const keys = lifecycleIn();
|
|
61
|
+
const first = await keys.create(async () => minted("a"));
|
|
62
|
+
await keys.create(async () => minted("b"));
|
|
63
|
+
|
|
64
|
+
expect(first).toEqual(minted("a").record);
|
|
65
|
+
expect(keys.getSnapshot().map((one) => one.key)).toEqual([minted("b").key, minted("a").key]);
|
|
66
|
+
keys.dismiss("b");
|
|
67
|
+
expect(keys.getSnapshot()).toEqual([minted("a")]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("refuses a record that still holds the secret", async () => {
|
|
71
|
+
const keys = lifecycleIn();
|
|
72
|
+
// The whole answer passed as the record: the shape every donor's mutation cache ended up with.
|
|
73
|
+
const { record, key } = minted("a");
|
|
74
|
+
const answer = { ...record, key };
|
|
75
|
+
await expect(keys.create(async () => ({ record: answer, key }))).rejects.toThrow(
|
|
76
|
+
"still holds the secret",
|
|
77
|
+
);
|
|
78
|
+
expect(keys.getSnapshot()).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("sends nothing before a scope is set", async () => {
|
|
82
|
+
const keys = lifecycleIn(null);
|
|
83
|
+
const request = vi.fn(async () => minted("a"));
|
|
84
|
+
await expect(keys.create(request)).rejects.toThrow("setScope");
|
|
85
|
+
await expect(keys.revoke("a", request)).rejects.toThrow("setScope");
|
|
86
|
+
expect(request).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("a new scope forgets every secret and refuses answers from the old one", async () => {
|
|
90
|
+
const keys = lifecycleIn("account-1:workspace-1");
|
|
91
|
+
await keys.create(async () => minted("a"));
|
|
92
|
+
keys.setScope("account-1:workspace-1");
|
|
93
|
+
expect(keys.getSnapshot()).toHaveLength(1);
|
|
94
|
+
|
|
95
|
+
const late = deferred<RevealedKey<KeyRecord>>();
|
|
96
|
+
const creating = keys.create(() => late.promise);
|
|
97
|
+
// Signed out and straight back in as the same account: still not the scope that asked.
|
|
98
|
+
keys.setScope(null);
|
|
99
|
+
keys.setScope("account-1:workspace-1");
|
|
100
|
+
late.resolve(minted("b"));
|
|
101
|
+
|
|
102
|
+
const refused = await creating.catch((error: unknown) => error);
|
|
103
|
+
expect(refused).toBeInstanceOf(KeyScopeChangedError);
|
|
104
|
+
expect(refused).toMatchObject({ operation: "create" });
|
|
105
|
+
expect(keys.getSnapshot()).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("a failed revoke keeps the secret, and a success forgets only that key", async () => {
|
|
109
|
+
const keys = lifecycleIn();
|
|
110
|
+
await keys.create(async () => minted("a"));
|
|
111
|
+
await keys.create(async () => minted("b"));
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
keys.revoke("a", async () => Promise.reject(new Error("offline"))),
|
|
115
|
+
).rejects.toThrow("offline");
|
|
116
|
+
expect(keys.getSnapshot()).toHaveLength(2);
|
|
117
|
+
|
|
118
|
+
await keys.revoke("a", async () => undefined);
|
|
119
|
+
expect(keys.getSnapshot()).toEqual([minted("b")]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("a revoke answered after the scope changed forgets nothing in the new one", async () => {
|
|
123
|
+
const keys = lifecycleIn("account-1:workspace-1");
|
|
124
|
+
const late = deferred<void>();
|
|
125
|
+
const revoking = keys.revoke("a", () => late.promise);
|
|
126
|
+
keys.setScope("account-1:workspace-2");
|
|
127
|
+
await keys.create(async () => minted("a"));
|
|
128
|
+
late.resolve();
|
|
129
|
+
|
|
130
|
+
await expect(revoking).rejects.toMatchObject({ operation: "revoke" });
|
|
131
|
+
expect(keys.getSnapshot()).toEqual([minted("a")]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("asks before the tab closes only while a secret is unread, per lifecycle", async () => {
|
|
135
|
+
const one = lifecycleIn();
|
|
136
|
+
const other = lifecycleIn();
|
|
137
|
+
expect(closingPrompts()).toBe(false);
|
|
138
|
+
|
|
139
|
+
await one.create(async () => minted("a"));
|
|
140
|
+
await other.create(async () => minted("b"));
|
|
141
|
+
expect(closingPrompts()).toBe(true);
|
|
142
|
+
|
|
143
|
+
other.dismiss("b");
|
|
144
|
+
expect(closingPrompts()).toBe(true);
|
|
145
|
+
one.setScope(null);
|
|
146
|
+
expect(closingPrompts()).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("with react-query", () => {
|
|
151
|
+
function app(keys: KeyLifecycle<KeyRecord>, server: { list: KeyRecord[] }) {
|
|
152
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
153
|
+
const element = document.createElement("div");
|
|
154
|
+
document.body.append(element);
|
|
155
|
+
const root = createRoot(element);
|
|
156
|
+
cleanups.push(() => act(() => root.unmount()));
|
|
157
|
+
let route: "keys" | "docs" = "keys";
|
|
158
|
+
let mint: ((request: () => Promise<RevealedKey<KeyRecord>>) => void) | undefined;
|
|
159
|
+
let shown: readonly RevealedKey<KeyRecord>[] = [];
|
|
160
|
+
|
|
161
|
+
function KeysPage() {
|
|
162
|
+
useQuery({ queryKey: ["keys"], queryFn: async () => server.list });
|
|
163
|
+
const create = useMutation({
|
|
164
|
+
mutationFn: (request: () => Promise<RevealedKey<KeyRecord>>) => keys.create(request),
|
|
165
|
+
onSuccess: () => client.invalidateQueries({ queryKey: ["keys"] }),
|
|
166
|
+
});
|
|
167
|
+
const reveals = useKeyReveals(keys);
|
|
168
|
+
useLayoutEffect(() => {
|
|
169
|
+
mint = create.mutate;
|
|
170
|
+
shown = reveals;
|
|
171
|
+
});
|
|
172
|
+
return createElement("p", null, reveals.map((one) => one.key).join(","));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const render = () =>
|
|
176
|
+
root.render(
|
|
177
|
+
createElement(
|
|
178
|
+
QueryClientProvider,
|
|
179
|
+
{ client },
|
|
180
|
+
route === "keys" ? createElement(KeysPage) : createElement("p", null, "docs"),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
act(render);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
client,
|
|
187
|
+
element,
|
|
188
|
+
get shown() {
|
|
189
|
+
return shown;
|
|
190
|
+
},
|
|
191
|
+
mint(request: () => Promise<RevealedKey<KeyRecord>>) {
|
|
192
|
+
act(() => mint?.(request));
|
|
193
|
+
},
|
|
194
|
+
go(next: "keys" | "docs") {
|
|
195
|
+
route = next;
|
|
196
|
+
act(render);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it("a create in flight still reveals its secret after the page unmounts", async () => {
|
|
202
|
+
const keys = lifecycleIn();
|
|
203
|
+
const screen = app(keys, { list: [] });
|
|
204
|
+
const late = deferred<RevealedKey<KeyRecord>>();
|
|
205
|
+
screen.mint(() => late.promise);
|
|
206
|
+
|
|
207
|
+
screen.go("docs");
|
|
208
|
+
await act(async () => late.resolve(minted("a")));
|
|
209
|
+
expect(keys.getSnapshot()).toEqual([minted("a")]);
|
|
210
|
+
|
|
211
|
+
screen.go("keys");
|
|
212
|
+
expect(screen.shown).toEqual([minted("a")]);
|
|
213
|
+
expect(screen.element.textContent).toBe(minted("a").key);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("no secret reaches the mutation cache or the list cache", async () => {
|
|
217
|
+
const keys = lifecycleIn();
|
|
218
|
+
const server = { list: [] as KeyRecord[] };
|
|
219
|
+
const screen = app(keys, server);
|
|
220
|
+
const { record, key } = minted("a");
|
|
221
|
+
|
|
222
|
+
screen.mint(async () => {
|
|
223
|
+
server.list = [record];
|
|
224
|
+
return { record, key };
|
|
225
|
+
});
|
|
226
|
+
await act(() =>
|
|
227
|
+
vi.waitFor(() => expect(screen.client.getQueryData(["keys"])).toEqual([record])),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const caches = JSON.stringify([
|
|
231
|
+
screen.client
|
|
232
|
+
.getMutationCache()
|
|
233
|
+
.getAll()
|
|
234
|
+
.map((mutation) => mutation.state),
|
|
235
|
+
screen.client
|
|
236
|
+
.getQueryCache()
|
|
237
|
+
.getAll()
|
|
238
|
+
.map((query) => query.state),
|
|
239
|
+
]);
|
|
240
|
+
expect(caches).toContain(record.prefix);
|
|
241
|
+
expect(caches).not.toContain(key);
|
|
242
|
+
expect(keys.getSnapshot()).toEqual([{ record, key }]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("a prerender prints no secret", async () => {
|
|
246
|
+
const keys = lifecycleIn();
|
|
247
|
+
await keys.create(async () => minted("a"));
|
|
248
|
+
function Reveals() {
|
|
249
|
+
return createElement("p", null, useKeyReveals(keys).length);
|
|
250
|
+
}
|
|
251
|
+
expect(renderToString(createElement(Reveals))).toBe("<p>0</p>");
|
|
252
|
+
});
|
|
253
|
+
});
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A new API key, the one time the server sends its secret. Three products wrote this flow, and
|
|
5
|
+
* each knew one part the others missed:
|
|
6
|
+
*
|
|
7
|
+
* - One kept unread secrets in a list outside the page, so a second key did not overwrite the
|
|
8
|
+
* first and a trip to the docs did not lose either. The tab asked before closing only while one
|
|
9
|
+
* was unread, and signing out forgot them all.
|
|
10
|
+
* - One kept the secret out of the list cache, folding only the record into it.
|
|
11
|
+
* - None kept it out of react-query's MUTATION cache. Each `mutationFn` resolved the whole answer,
|
|
12
|
+
* secret included, so `mutation.data` held it, one of them under a comment promising it did not.
|
|
13
|
+
*
|
|
14
|
+
* So `create` takes the secret here and resolves only the record, and the request is the caller's.
|
|
15
|
+
* Forms, permissions, list queries, invalidation and copy feedback stay in the product.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What your create request resolves: the record your list shows, and the secret beside it. */
|
|
19
|
+
export interface RevealedKey<Record> {
|
|
20
|
+
readonly record: Record;
|
|
21
|
+
readonly key: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The account or workspace changed while a create or revoke was in flight. */
|
|
25
|
+
export class KeyScopeChangedError extends Error {
|
|
26
|
+
readonly operation: "create" | "revoke";
|
|
27
|
+
|
|
28
|
+
constructor(operation: "create" | "revoke") {
|
|
29
|
+
super(
|
|
30
|
+
operation === "create"
|
|
31
|
+
? "The account or workspace changed before the new key could be shown. If nobody saved it, revoke it from that workspace's keys."
|
|
32
|
+
: "The account or workspace changed while the key was being revoked. Check that workspace's keys to see whether it worked.",
|
|
33
|
+
);
|
|
34
|
+
this.name = "KeyScopeChangedError";
|
|
35
|
+
this.operation = operation;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface KeyLifecycle<Record> {
|
|
40
|
+
/**
|
|
41
|
+
* Who and where keys are made for: the signed-in account and the workspace, joined into one
|
|
42
|
+
* string, or `null` once signed out. Call it from your auth and workspace subscription, never
|
|
43
|
+
* from the keys page, which may not be mounted. A new scope forgets every unread secret and
|
|
44
|
+
* rejects requests started under the old one, including when the same account signs back in.
|
|
45
|
+
*/
|
|
46
|
+
setScope(scope: string | null): void;
|
|
47
|
+
/**
|
|
48
|
+
* Run your create request, keep its secret here, and resolve only the record. Use it as a
|
|
49
|
+
* `mutationFn`: neither the result nor the variables carry the secret, and the reveal still
|
|
50
|
+
* lands when the page that asked has unmounted.
|
|
51
|
+
*/
|
|
52
|
+
create(request: () => Promise<RevealedKey<Record>>): Promise<Record>;
|
|
53
|
+
/** Run your revoke request. Only a success, in the same scope, forgets that key's secret. */
|
|
54
|
+
revoke(id: string, request: () => Promise<unknown>): Promise<void>;
|
|
55
|
+
/** The person saved it. Wire this to "Done", never to a copy: a copy can fail. */
|
|
56
|
+
dismiss(id: string): void;
|
|
57
|
+
/** Unread secrets, newest first. */
|
|
58
|
+
getSnapshot(): readonly RevealedKey<Record>[];
|
|
59
|
+
subscribe(listener: () => void): () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One per app, created at module scope, outside every route. Memory only: never storage, the URL
|
|
64
|
+
* or a query cache, so a reload loses an unread secret, and the tab asks before closing while one
|
|
65
|
+
* is unread. The listener comes and goes with the secrets because a page with a `beforeunload`
|
|
66
|
+
* listener is kept out of some browsers' back-forward cache.
|
|
67
|
+
*/
|
|
68
|
+
export function createKeyLifecycle<Record extends { readonly id: string }>(): KeyLifecycle<Record> {
|
|
69
|
+
let revealed: readonly RevealedKey<Record>[] = [];
|
|
70
|
+
let scope: string | null = null;
|
|
71
|
+
let generation = 0;
|
|
72
|
+
const listeners = new Set<() => void>();
|
|
73
|
+
|
|
74
|
+
// Per instance, so two lifecycles never remove each other's prompt.
|
|
75
|
+
function askBeforeLeaving(event: BeforeUnloadEvent): void {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
// Older Chrome and Safari prompt only when this is set too. The browser picks the words.
|
|
78
|
+
event.returnValue = "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function publish(next: readonly RevealedKey<Record>[]): void {
|
|
82
|
+
const wasUnread = revealed.length > 0;
|
|
83
|
+
revealed = next;
|
|
84
|
+
if (wasUnread !== next.length > 0 && typeof window !== "undefined") {
|
|
85
|
+
if (next.length > 0) window.addEventListener("beforeunload", askBeforeLeaving);
|
|
86
|
+
else window.removeEventListener("beforeunload", askBeforeLeaving);
|
|
87
|
+
}
|
|
88
|
+
for (const listener of listeners) listener();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Runs `request` under the current scope and refuses an answer that outlived it. */
|
|
92
|
+
async function scoped<T>(operation: "create" | "revoke", request: () => Promise<T>): Promise<T> {
|
|
93
|
+
if (scope === null)
|
|
94
|
+
throw new Error("Call setScope with the signed-in account and workspace first.");
|
|
95
|
+
const started = generation;
|
|
96
|
+
const answer = await request();
|
|
97
|
+
if (started !== generation) throw new KeyScopeChangedError(operation);
|
|
98
|
+
return answer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function dismiss(id: string): void {
|
|
102
|
+
const next = revealed.filter((one) => one.record.id !== id);
|
|
103
|
+
if (next.length !== revealed.length) publish(next);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
setScope(next) {
|
|
108
|
+
if (scope === next) return;
|
|
109
|
+
scope = next;
|
|
110
|
+
generation++;
|
|
111
|
+
if (revealed.length > 0) publish([]);
|
|
112
|
+
},
|
|
113
|
+
async create(request) {
|
|
114
|
+
const { record, key } = await scoped("create", request);
|
|
115
|
+
// The record goes on to react-query and often into the list cache. A request that passed
|
|
116
|
+
// the whole answer as the record would put the secret there, which is the bug this exists
|
|
117
|
+
// to end, so say so on the first try instead.
|
|
118
|
+
if (JSON.stringify(record).includes(key))
|
|
119
|
+
throw new Error("The record still holds the secret. Resolve it without the key field.");
|
|
120
|
+
publish([{ record, key }, ...revealed]);
|
|
121
|
+
return record;
|
|
122
|
+
},
|
|
123
|
+
async revoke(id, request) {
|
|
124
|
+
await scoped("revoke", request);
|
|
125
|
+
dismiss(id);
|
|
126
|
+
},
|
|
127
|
+
dismiss,
|
|
128
|
+
getSnapshot: () => revealed,
|
|
129
|
+
subscribe(listener) {
|
|
130
|
+
listeners.add(listener);
|
|
131
|
+
return () => listeners.delete(listener);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The server has no unread secrets, and a prerender must never print one.
|
|
137
|
+
const none: readonly never[] = [];
|
|
138
|
+
|
|
139
|
+
/** Unread secrets, newest first. Unmounting the component forgets nothing. */
|
|
140
|
+
export function useKeyReveals<Record>(
|
|
141
|
+
lifecycle: KeyLifecycle<Record>,
|
|
142
|
+
): readonly RevealedKey<Record>[] {
|
|
143
|
+
return useSyncExternalStore(lifecycle.subscribe, lifecycle.getSnapshot, () => none);
|
|
144
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
formatUserCode,
|
|
8
|
+
useConsentRequest,
|
|
9
|
+
useDeviceApproval,
|
|
10
|
+
type ConsentApi,
|
|
11
|
+
type ConsentView,
|
|
12
|
+
type DeviceApi,
|
|
13
|
+
type DeviceView,
|
|
14
|
+
type OAuthRefusal,
|
|
15
|
+
} from "./oauth.ts";
|
|
16
|
+
|
|
17
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
18
|
+
|
|
19
|
+
const roots = new Set<Root>();
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const root of roots) act(() => root.unmount());
|
|
22
|
+
roots.clear();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Renders `hook` under a fresh query client and keeps its latest answer in `current`. */
|
|
27
|
+
function mount<T>(hook: () => T) {
|
|
28
|
+
const seen: T[] = [];
|
|
29
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
30
|
+
function Probe() {
|
|
31
|
+
seen.push(hook());
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const root = createRoot(document.createElement("div"));
|
|
35
|
+
roots.add(root);
|
|
36
|
+
act(() => root.render(createElement(QueryClientProvider, { client }, createElement(Probe))));
|
|
37
|
+
return {
|
|
38
|
+
client,
|
|
39
|
+
get current(): T {
|
|
40
|
+
const last = seen.at(-1);
|
|
41
|
+
if (last === undefined) throw new Error("The hook never rendered");
|
|
42
|
+
return last;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Lets pending promises and the renders they cause finish. react-query hands its updates to a
|
|
49
|
+
* scheduler that runs on a later task, so one tick is not always enough.
|
|
50
|
+
*/
|
|
51
|
+
const settle = () =>
|
|
52
|
+
act(async () => {
|
|
53
|
+
for (let tick = 0; tick < 5; tick++) await new Promise((resolve) => setTimeout(resolve, 0));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const VIEW: ConsentView = {
|
|
57
|
+
ok: true,
|
|
58
|
+
clientName: "Claude",
|
|
59
|
+
redirectHost: "claude.ai",
|
|
60
|
+
keyName: "Claude (claude.ai)",
|
|
61
|
+
cancelUrl: "https://claude.ai/cb?error=access_denied",
|
|
62
|
+
};
|
|
63
|
+
const REFUSAL: OAuthRefusal = {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: "redirect-mismatch",
|
|
66
|
+
message: "This app's sign-in link is not valid.",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
describe("useConsentRequest", () => {
|
|
70
|
+
it("loads the request, approves with the page's choice, and leaves for the redirect", async () => {
|
|
71
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
72
|
+
let release!: () => void;
|
|
73
|
+
const api: ConsentApi<string> = {
|
|
74
|
+
describe: vi.fn(async () => VIEW),
|
|
75
|
+
approve: vi.fn(
|
|
76
|
+
() =>
|
|
77
|
+
new Promise<{ ok: true; redirectTo: string }>((resolve) => {
|
|
78
|
+
release = () => resolve({ ok: true, redirectTo: "https://claude.ai/cb?code=c1" });
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
const page = mount(() => useConsentRequest(api, "?client_id=x&state=s"));
|
|
83
|
+
expect(page.current.state).toEqual({ status: "loading" });
|
|
84
|
+
await settle();
|
|
85
|
+
expect(page.current.state).toEqual({ status: "ready", view: VIEW });
|
|
86
|
+
expect(api.describe).toHaveBeenCalledWith("client_id=x&state=s");
|
|
87
|
+
|
|
88
|
+
act(() => page.current.approve("ws-1"));
|
|
89
|
+
await settle();
|
|
90
|
+
expect(api.approve).toHaveBeenCalledWith("client_id=x&state=s", "ws-1");
|
|
91
|
+
expect(page.current.leaving).toBe(true);
|
|
92
|
+
|
|
93
|
+
release();
|
|
94
|
+
await settle();
|
|
95
|
+
expect(assign).toHaveBeenCalledWith("https://claude.ai/cb?code=c1");
|
|
96
|
+
// The page is going away: the button must not come back for a second press.
|
|
97
|
+
expect(page.current.leaving).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("shows a refusal as a state and never redirects on it", async () => {
|
|
101
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
102
|
+
const page = mount(() =>
|
|
103
|
+
useConsentRequest({ describe: async () => REFUSAL, approve: vi.fn() }, "?client_id=x"),
|
|
104
|
+
);
|
|
105
|
+
await settle();
|
|
106
|
+
expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
|
|
107
|
+
expect(assign).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("turns a refusal from approve into the refused state, and stays put", async () => {
|
|
111
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
112
|
+
const page = mount(() =>
|
|
113
|
+
useConsentRequest({ describe: async () => VIEW, approve: async () => REFUSAL }, ""),
|
|
114
|
+
);
|
|
115
|
+
await settle();
|
|
116
|
+
act(() => page.current.approve());
|
|
117
|
+
await settle();
|
|
118
|
+
expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
|
|
119
|
+
expect(page.current.leaving).toBe(false);
|
|
120
|
+
expect(assign).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("offers a retry when the request failed to answer, and recovers", async () => {
|
|
124
|
+
let calls = 0;
|
|
125
|
+
const page = mount(() =>
|
|
126
|
+
useConsentRequest(
|
|
127
|
+
{
|
|
128
|
+
describe: async () => {
|
|
129
|
+
calls += 1;
|
|
130
|
+
if (calls === 1) throw new TypeError("Failed to fetch");
|
|
131
|
+
return VIEW;
|
|
132
|
+
},
|
|
133
|
+
approve: vi.fn(),
|
|
134
|
+
},
|
|
135
|
+
"?a=1",
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
await settle();
|
|
139
|
+
const failed = page.current.state;
|
|
140
|
+
if (failed.status !== "failed") throw new Error(`expected failed, got ${failed.status}`);
|
|
141
|
+
expect(failed.error).toBeInstanceOf(TypeError);
|
|
142
|
+
act(() => failed.retry());
|
|
143
|
+
await settle();
|
|
144
|
+
expect(page.current.state).toEqual({ status: "ready", view: VIEW });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps an approve that did not answer beside the button, ready to press again", async () => {
|
|
148
|
+
const page = mount(() =>
|
|
149
|
+
useConsentRequest(
|
|
150
|
+
{
|
|
151
|
+
describe: async () => VIEW,
|
|
152
|
+
approve: async () => {
|
|
153
|
+
throw new TypeError("Failed to fetch");
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
"",
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
await settle();
|
|
160
|
+
act(() => page.current.approve());
|
|
161
|
+
await settle();
|
|
162
|
+
expect(page.current.approveError).toBeInstanceOf(TypeError);
|
|
163
|
+
expect(page.current.leaving).toBe(false);
|
|
164
|
+
expect(page.current.state.status).toBe("ready");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const DEVICE: DeviceView = {
|
|
169
|
+
ok: true,
|
|
170
|
+
clientName: "Acme CLI",
|
|
171
|
+
userCode: "WDJB-MJHT",
|
|
172
|
+
keyName: "Acme CLI (device sign-in)",
|
|
173
|
+
expiresAt: "2026-10-16T12:10:00.000Z",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
describe("useDeviceApproval", () => {
|
|
177
|
+
function deviceApi(overrides: Partial<DeviceApi> = {}) {
|
|
178
|
+
return {
|
|
179
|
+
describe: vi.fn<DeviceApi["describe"]>(overrides.describe ?? (async () => DEVICE)),
|
|
180
|
+
decide: vi.fn<DeviceApi["decide"]>(overrides.decide ?? (async () => ({ ok: true }))),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
it("starts from the link's code, asks the server, and stays approved after the yes", async () => {
|
|
185
|
+
const api = deviceApi();
|
|
186
|
+
const page = mount(() => useDeviceApproval(api, "?code=wdjb-mjht"));
|
|
187
|
+
expect(page.current.code).toBe("WDJB-MJHT");
|
|
188
|
+
await settle();
|
|
189
|
+
expect(page.current.state).toEqual({ status: "ready", view: DEVICE });
|
|
190
|
+
expect(api.describe).toHaveBeenCalledWith("WDJBMJHT");
|
|
191
|
+
|
|
192
|
+
act(() => page.current.approve());
|
|
193
|
+
await settle();
|
|
194
|
+
expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve");
|
|
195
|
+
expect(page.current.state).toEqual({ status: "approved" });
|
|
196
|
+
|
|
197
|
+
// A refetch on focus would now hear "unknown code". It must not be asked.
|
|
198
|
+
api.describe.mockResolvedValue({ ok: false, reason: "unknown-code", message: "gone" });
|
|
199
|
+
await act(async () => page.client.invalidateQueries());
|
|
200
|
+
await settle();
|
|
201
|
+
expect(page.current.state).toEqual({ status: "approved" });
|
|
202
|
+
expect(api.describe).toHaveBeenCalledTimes(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("waits for all eight letters before asking, and flags a vowel at once", async () => {
|
|
206
|
+
const api = deviceApi();
|
|
207
|
+
const page = mount(() => useDeviceApproval(api, ""));
|
|
208
|
+
expect(page.current.state).toEqual({ status: "typing" });
|
|
209
|
+
act(() => page.current.setCode("wdjb m"));
|
|
210
|
+
expect(page.current.code).toBe("WDJB-M");
|
|
211
|
+
act(() => page.current.setCode("WDJA"));
|
|
212
|
+
expect(page.current.invalidLetter).toBe(true);
|
|
213
|
+
act(() => page.current.setCode("WDJB MJHTX"));
|
|
214
|
+
expect(page.current.code).toBe("WDJB-MJHT");
|
|
215
|
+
await settle();
|
|
216
|
+
expect(api.describe).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(page.current.state.status).toBe("ready");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("says no, and shows a code the server does not know as refused", async () => {
|
|
221
|
+
const said = mount(() => useDeviceApproval(deviceApi(), "?code=WDJBMJHT"));
|
|
222
|
+
await settle();
|
|
223
|
+
act(() => said.current.deny());
|
|
224
|
+
await settle();
|
|
225
|
+
expect(said.current.state).toEqual({ status: "denied" });
|
|
226
|
+
|
|
227
|
+
const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
|
|
228
|
+
const gone = mount(() =>
|
|
229
|
+
useDeviceApproval(deviceApi({ describe: async () => unknown }), "?code=WDJBMJHT"),
|
|
230
|
+
);
|
|
231
|
+
await settle();
|
|
232
|
+
expect(gone.current.state).toEqual({ status: "refused", refusal: unknown });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("shows a decision the server refused, and lets a new code start over", async () => {
|
|
236
|
+
const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
|
|
237
|
+
const api = deviceApi({ decide: async () => unknown });
|
|
238
|
+
const page = mount(() => useDeviceApproval(api, "?code=WDJBMJHT"));
|
|
239
|
+
await settle();
|
|
240
|
+
act(() => page.current.approve());
|
|
241
|
+
await settle();
|
|
242
|
+
expect(page.current.state).toEqual({ status: "refused", refusal: unknown });
|
|
243
|
+
act(() => page.current.setCode("BCDF GHJK"));
|
|
244
|
+
await settle();
|
|
245
|
+
expect(page.current.state.status).toBe("ready");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("formatUserCode", () => {
|
|
250
|
+
it("writes what the field shows", () => {
|
|
251
|
+
expect(formatUserCode("")).toBe("");
|
|
252
|
+
expect(formatUserCode("wdj")).toBe("WDJ");
|
|
253
|
+
expect(formatUserCode("wdjb")).toBe("WDJB");
|
|
254
|
+
expect(formatUserCode("wdjbm")).toBe("WDJB-M");
|
|
255
|
+
expect(formatUserCode(" w-d j b m j h t z ")).toBe("WDJB-MJHT");
|
|
256
|
+
});
|
|
257
|
+
});
|