assistant-ui 0.0.119 → 0.0.121
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 +60 -1
- package/dist/commands/cloud.d.ts +25 -0
- package/dist/commands/cloud.d.ts.map +1 -0
- package/dist/commands/cloud.js +194 -0
- package/dist/commands/cloud.js.map +1 -0
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +8 -0
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/info.js +1 -1
- package/dist/lib/agent-skill.d.ts +2 -2
- package/dist/lib/agent-skill.js +1 -1
- package/dist/lib/agent-skill.js.map +1 -1
- package/dist/lib/cloud-abort.d.ts +2 -0
- package/dist/lib/cloud-abort.d.ts.map +1 -0
- package/dist/lib/cloud-abort.js +13 -0
- package/dist/lib/cloud-abort.js.map +1 -0
- package/dist/lib/cloud-auth.d.ts +28 -0
- package/dist/lib/cloud-auth.d.ts.map +1 -0
- package/dist/lib/cloud-auth.js +169 -0
- package/dist/lib/cloud-auth.js.map +1 -0
- package/dist/lib/cloud-config.d.ts +21 -0
- package/dist/lib/cloud-config.d.ts.map +1 -0
- package/dist/lib/cloud-config.js +76 -0
- package/dist/lib/cloud-config.js.map +1 -0
- package/dist/lib/cloud-provision.d.ts +20 -0
- package/dist/lib/cloud-provision.d.ts.map +1 -0
- package/dist/lib/cloud-provision.js +55 -0
- package/dist/lib/cloud-provision.js.map +1 -0
- package/dist/lib/cloud-setup-login.d.ts +9 -0
- package/dist/lib/cloud-setup-login.d.ts.map +1 -0
- package/dist/lib/cloud-setup-login.js +137 -0
- package/dist/lib/cloud-setup-login.js.map +1 -0
- package/dist/lib/cloud-url.d.ts +2 -0
- package/dist/lib/cloud-url.d.ts.map +1 -0
- package/dist/lib/cloud-url.js +14 -0
- package/dist/lib/cloud-url.js.map +1 -0
- package/dist/lib/transform.js +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +2 -1
- package/dist/program.js.map +1 -1
- package/package.json +17 -13
- package/src/commands/cloud.test.ts +238 -0
- package/src/commands/cloud.ts +455 -0
- package/src/commands/create.ts +8 -0
- package/src/lib/agent-skill.ts +1 -1
- package/src/lib/cloud-abort.ts +12 -0
- package/src/lib/cloud-auth.test.ts +500 -0
- package/src/lib/cloud-auth.ts +273 -0
- package/src/lib/cloud-config.test.ts +82 -0
- package/src/lib/cloud-config.ts +138 -0
- package/src/lib/cloud-provision.test.ts +132 -0
- package/src/lib/cloud-provision.ts +93 -0
- package/src/lib/cloud-setup-login.test.ts +361 -0
- package/src/lib/cloud-setup-login.ts +240 -0
- package/src/lib/cloud-url.ts +19 -0
- package/src/program.ts +2 -0
- package/templates/cloud-harness/.assistant-ui/template.json +3 -0
- package/templates/cloud-harness/.env.example +1 -0
- package/templates/cloud-harness/.gitignore +42 -0
- package/templates/cloud-harness/README.md +28 -0
- package/templates/cloud-harness/app/api/chat/route.ts +13 -0
- package/templates/cloud-harness/app/api/credential/route.ts +14 -0
- package/templates/cloud-harness/app/assistant.tsx +83 -0
- package/templates/cloud-harness/app/favicon.ico +0 -0
- package/templates/cloud-harness/app/globals.css +139 -0
- package/templates/cloud-harness/app/layout.tsx +37 -0
- package/templates/cloud-harness/app/page.tsx +5 -0
- package/templates/cloud-harness/components/assistant-ui/elements/attachment.aui.tsx +279 -0
- package/templates/cloud-harness/components/assistant-ui/elements/markdown-text.tsx +238 -0
- package/templates/cloud-harness/components/assistant-ui/elements/thread.aui.tsx +521 -0
- package/templates/cloud-harness/components/assistant-ui/elements/tool-fallback.aui.tsx +1153 -0
- package/templates/cloud-harness/components/assistant-ui/elements/tooltip-icon-button.tsx +49 -0
- package/templates/cloud-harness/components/ui/avatar.tsx +109 -0
- package/templates/cloud-harness/components/ui/button.tsx +58 -0
- package/templates/cloud-harness/components/ui/collapsible.tsx +21 -0
- package/templates/cloud-harness/components/ui/dialog.tsx +159 -0
- package/templates/cloud-harness/components/ui/textarea.tsx +18 -0
- package/templates/cloud-harness/components/ui/tooltip.tsx +69 -0
- package/templates/cloud-harness/components.json +24 -0
- package/templates/cloud-harness/hooks/use-attachment-src.ts +46 -0
- package/templates/cloud-harness/hooks/use-copy-to-clipboard.ts +54 -0
- package/templates/cloud-harness/lib/utils.ts +1 -0
- package/templates/cloud-harness/next.config.ts +6 -0
- package/templates/cloud-harness/package.json +51 -0
- package/templates/cloud-harness/postcss.config.mjs +5 -0
- package/templates/cloud-harness/tsconfig.json +33 -0
- package/templates/cloud-harness/vendor/README.md +13 -0
- package/templates/cloud-harness/vendor/assistant-ui-react-harness-sdk-0.0.2.tgz +0 -0
- package/templates/cloud-harness/vendor/harness-sdk-0.3.1.tgz +0 -0
- package/templates/cloud-harness/vendor/provenance.json +38 -0
- package/templates/cloud-harness/vendor/statewire-0.19.3.tgz +0 -0
- /package/{LICENSE → templates/cloud-harness/vendor/LICENSE} +0 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
mkdtemp,
|
|
4
|
+
readFile,
|
|
5
|
+
rm,
|
|
6
|
+
stat,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
cloudAccessToken,
|
|
14
|
+
loginToCloud,
|
|
15
|
+
logoutFromCloud,
|
|
16
|
+
readCloudCredentials,
|
|
17
|
+
saveCloudCredentials,
|
|
18
|
+
type CloudAuthConfig,
|
|
19
|
+
validateCloudUrl,
|
|
20
|
+
} from "./cloud-auth";
|
|
21
|
+
|
|
22
|
+
const { login, createDeviceLogin, isAccessDenied } = vi.hoisted(() => ({
|
|
23
|
+
login: {
|
|
24
|
+
start: vi.fn(),
|
|
25
|
+
wait: vi.fn(),
|
|
26
|
+
ensureFresh: vi.fn(),
|
|
27
|
+
revoke: vi.fn(),
|
|
28
|
+
},
|
|
29
|
+
createDeviceLogin: vi.fn(),
|
|
30
|
+
isAccessDenied: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("aui-auth/device", async (importOriginal) => ({
|
|
34
|
+
...(await importOriginal<typeof import("aui-auth/device")>()),
|
|
35
|
+
createDeviceLogin: (...args: unknown[]) => {
|
|
36
|
+
createDeviceLogin(...args);
|
|
37
|
+
return login;
|
|
38
|
+
},
|
|
39
|
+
isAccessDenied,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const filesystem = vi.hoisted(() => ({
|
|
43
|
+
rename: undefined as
|
|
44
|
+
| undefined
|
|
45
|
+
| ((from: string, to: string, run: () => Promise<void>) => Promise<void>),
|
|
46
|
+
chmod: undefined as
|
|
47
|
+
| undefined
|
|
48
|
+
| ((file: string, mode: number, run: () => Promise<void>) => Promise<void>),
|
|
49
|
+
}));
|
|
50
|
+
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
51
|
+
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
52
|
+
return {
|
|
53
|
+
...actual,
|
|
54
|
+
rename: (from: string, to: string) =>
|
|
55
|
+
filesystem.rename
|
|
56
|
+
? filesystem.rename(from, to, () => actual.rename(from, to))
|
|
57
|
+
: actual.rename(from, to),
|
|
58
|
+
chmod: (file: string, mode: number) =>
|
|
59
|
+
filesystem.chmod
|
|
60
|
+
? filesystem.chmod(file, mode, () => actual.chmod(file, mode))
|
|
61
|
+
: actual.chmod(file, mode),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const setupLogin = vi.hoisted(() => ({ connect: vi.fn() }));
|
|
66
|
+
vi.mock("./cloud-setup-login", async (importOriginal) => ({
|
|
67
|
+
...(await importOriginal<typeof import("./cloud-setup-login")>()),
|
|
68
|
+
connectCloudLoginSetup: setupLogin.connect,
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const directories: string[] = [];
|
|
72
|
+
const fixture = async (): Promise<CloudAuthConfig> => {
|
|
73
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "assistant-ui-auth-"));
|
|
74
|
+
directories.push(directory);
|
|
75
|
+
return {
|
|
76
|
+
issuer: "https://accounts.example.com",
|
|
77
|
+
clientId: "assistant-ui-cli",
|
|
78
|
+
credentialsFile: path.join(directory, "credentials.json"),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const credentials = {
|
|
83
|
+
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
|
84
|
+
tokens: {
|
|
85
|
+
accessToken: "access-token",
|
|
86
|
+
refreshToken: "refresh-token",
|
|
87
|
+
idToken: null,
|
|
88
|
+
accessTokenExpiresAt: Date.now() + 60_000,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
afterEach(async () => {
|
|
93
|
+
filesystem.rename = undefined;
|
|
94
|
+
filesystem.chmod = undefined;
|
|
95
|
+
vi.unstubAllGlobals();
|
|
96
|
+
await Promise.all(
|
|
97
|
+
directories
|
|
98
|
+
.splice(0)
|
|
99
|
+
.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("cloud login", () => {
|
|
104
|
+
it("allows secure hosted origins and loopback development but rejects plaintext remote origins", () => {
|
|
105
|
+
expect(validateCloudUrl("https://cloud.example.com/")).toBe(
|
|
106
|
+
"https://cloud.example.com",
|
|
107
|
+
);
|
|
108
|
+
expect(validateCloudUrl("http://localhost:3000")).toBe(
|
|
109
|
+
"http://localhost:3000",
|
|
110
|
+
);
|
|
111
|
+
expect(() => validateCloudUrl("http://cloud.example.com")).toThrow("HTTPS");
|
|
112
|
+
expect(() =>
|
|
113
|
+
validateCloudUrl("https://cloud.example.com?key=secret"),
|
|
114
|
+
).toThrow();
|
|
115
|
+
expect(() =>
|
|
116
|
+
validateCloudUrl("https://cloud.example.com#fragment"),
|
|
117
|
+
).toThrow();
|
|
118
|
+
});
|
|
119
|
+
it("saves approved device credentials with private permissions and no token output", async () => {
|
|
120
|
+
const config = await fixture();
|
|
121
|
+
login.start.mockResolvedValue({
|
|
122
|
+
userCode: "ABCD",
|
|
123
|
+
verificationUriComplete: "https://accounts.example.com/device?code=ABCD",
|
|
124
|
+
});
|
|
125
|
+
login.wait.mockResolvedValue(credentials);
|
|
126
|
+
const print = vi.fn();
|
|
127
|
+
await loginToCloud(config, { noOpen: true, print });
|
|
128
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
129
|
+
if (process.platform !== "win32")
|
|
130
|
+
expect((await stat(config.credentialsFile)).mode & 0o777).toBe(0o600);
|
|
131
|
+
expect(JSON.stringify(print.mock.calls)).not.toContain("access-token");
|
|
132
|
+
expect(JSON.stringify(print.mock.calls)).not.toContain("refresh-token");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("persists rotated refresh credentials before another command uses them", async () => {
|
|
136
|
+
const config = await fixture();
|
|
137
|
+
await saveCloudCredentials(config, credentials);
|
|
138
|
+
login.ensureFresh.mockResolvedValue({
|
|
139
|
+
...credentials,
|
|
140
|
+
tokens: {
|
|
141
|
+
...credentials.tokens,
|
|
142
|
+
accessToken: "rotated-access",
|
|
143
|
+
refreshToken: "rotated-refresh",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(await cloudAccessToken(config)).toBe("rotated-access");
|
|
147
|
+
expect(
|
|
148
|
+
JSON.parse(await readFile(config.credentialsFile, "utf8")).tokens
|
|
149
|
+
.refreshToken,
|
|
150
|
+
).toBe("rotated-refresh");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not discard credentials on a transient refresh failure", async () => {
|
|
154
|
+
const config = await fixture();
|
|
155
|
+
await saveCloudCredentials(config, credentials);
|
|
156
|
+
login.ensureFresh.mockRejectedValue(new Error("network unavailable"));
|
|
157
|
+
isAccessDenied.mockReturnValue(false);
|
|
158
|
+
await expect(cloudAccessToken(config)).rejects.toThrow(
|
|
159
|
+
"network unavailable",
|
|
160
|
+
);
|
|
161
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("revokes the login before forgetting its refresh token", async () => {
|
|
165
|
+
const config = await fixture();
|
|
166
|
+
await saveCloudCredentials(config, credentials);
|
|
167
|
+
login.revoke.mockImplementation(async () => {
|
|
168
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
169
|
+
});
|
|
170
|
+
await logoutFromCloud(config);
|
|
171
|
+
expect(login.revoke).toHaveBeenCalledWith(
|
|
172
|
+
expect.objectContaining(credentials),
|
|
173
|
+
);
|
|
174
|
+
expect(await readCloudCredentials(config)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does not reuse credentials belonging to a different issuer", async () => {
|
|
178
|
+
const config = await fixture();
|
|
179
|
+
await saveCloudCredentials(config, credentials);
|
|
180
|
+
expect(
|
|
181
|
+
await readCloudCredentials({
|
|
182
|
+
...config,
|
|
183
|
+
issuer: "https://different.example.com",
|
|
184
|
+
}),
|
|
185
|
+
).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("revokes at the stored issuer even if configuration has changed", async () => {
|
|
189
|
+
const config = await fixture();
|
|
190
|
+
await saveCloudCredentials(config, credentials);
|
|
191
|
+
login.revoke.mockResolvedValue(undefined);
|
|
192
|
+
await logoutFromCloud({
|
|
193
|
+
...config,
|
|
194
|
+
issuer: "https://other.example.com",
|
|
195
|
+
clientId: "other",
|
|
196
|
+
});
|
|
197
|
+
expect(createDeviceLogin).toHaveBeenLastCalledWith({
|
|
198
|
+
issuer: config.issuer,
|
|
199
|
+
clientId: config.clientId,
|
|
200
|
+
});
|
|
201
|
+
expect(await readCloudCredentials(config)).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("leaves previous credentials untouched when cancellation arrives during temporary chmod", async () => {
|
|
205
|
+
const config = await fixture();
|
|
206
|
+
await saveCloudCredentials(config, credentials);
|
|
207
|
+
const previous = await stat(config.credentialsFile);
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
filesystem.chmod = async (_file, _mode, run) => {
|
|
210
|
+
await run();
|
|
211
|
+
controller.abort(new Error("cancelled during chmod"));
|
|
212
|
+
};
|
|
213
|
+
await expect(
|
|
214
|
+
saveCloudCredentials(
|
|
215
|
+
config,
|
|
216
|
+
{ ...credentials, user: { ...credentials.user, id: "new-user" } },
|
|
217
|
+
controller.signal,
|
|
218
|
+
),
|
|
219
|
+
).rejects.toThrow("cancelled during chmod");
|
|
220
|
+
expect((await stat(config.credentialsFile)).ino).toBe(previous.ino);
|
|
221
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
222
|
+
});
|
|
223
|
+
it.each([true, false])(
|
|
224
|
+
"rolls back a cancelled rename with previous credentials: %s",
|
|
225
|
+
async (existing) => {
|
|
226
|
+
const config = await fixture();
|
|
227
|
+
if (existing) {
|
|
228
|
+
await saveCloudCredentials(config, credentials);
|
|
229
|
+
await chmod(config.credentialsFile, 0o640);
|
|
230
|
+
}
|
|
231
|
+
const previous = existing
|
|
232
|
+
? await stat(config.credentialsFile)
|
|
233
|
+
: undefined;
|
|
234
|
+
const controller = new AbortController();
|
|
235
|
+
filesystem.rename = async (_from, _to, run) => {
|
|
236
|
+
await run();
|
|
237
|
+
controller.abort(new Error("cancelled during rename"));
|
|
238
|
+
};
|
|
239
|
+
await expect(
|
|
240
|
+
saveCloudCredentials(
|
|
241
|
+
config,
|
|
242
|
+
{ ...credentials, user: { ...credentials.user, id: "new-user" } },
|
|
243
|
+
controller.signal,
|
|
244
|
+
),
|
|
245
|
+
).rejects.toThrow("cancelled during rename");
|
|
246
|
+
if (existing) {
|
|
247
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
248
|
+
expect((await stat(config.credentialsFile)).ino).toBe(previous!.ino);
|
|
249
|
+
expect((await stat(config.credentialsFile)).mode).toBe(previous!.mode);
|
|
250
|
+
} else expect(await readCloudCredentials(config)).toBeNull();
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
it("preserves a newer concurrent replacement when its own rename is cancelled", async () => {
|
|
254
|
+
const config = await fixture();
|
|
255
|
+
await saveCloudCredentials(config, credentials);
|
|
256
|
+
const controller = new AbortController();
|
|
257
|
+
filesystem.rename = async (_from, _to, run) => {
|
|
258
|
+
await run();
|
|
259
|
+
filesystem.rename = undefined;
|
|
260
|
+
await saveCloudCredentials(config, {
|
|
261
|
+
...credentials,
|
|
262
|
+
user: { ...credentials.user, id: "other-login" },
|
|
263
|
+
});
|
|
264
|
+
controller.abort(new Error("cancelled during rename"));
|
|
265
|
+
};
|
|
266
|
+
await expect(
|
|
267
|
+
saveCloudCredentials(
|
|
268
|
+
config,
|
|
269
|
+
{
|
|
270
|
+
...credentials,
|
|
271
|
+
user: { ...credentials.user, id: "cancelled-login" },
|
|
272
|
+
},
|
|
273
|
+
controller.signal,
|
|
274
|
+
),
|
|
275
|
+
).rejects.toThrow("cancelled during rename");
|
|
276
|
+
expect((await readCloudCredentials(config))!.user.id).toBe("other-login");
|
|
277
|
+
});
|
|
278
|
+
it("reports malformed saved JSON values without reading their fields", async () => {
|
|
279
|
+
const config = await fixture();
|
|
280
|
+
await writeFile(config.credentialsFile, "null");
|
|
281
|
+
await expect(readCloudCredentials(config)).rejects.toThrow(
|
|
282
|
+
"Invalid saved login",
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("cloud login through the setup wizard", () => {
|
|
288
|
+
const bridge = () => {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const setup = {
|
|
291
|
+
signal: controller.signal,
|
|
292
|
+
publish: vi.fn().mockResolvedValue(undefined),
|
|
293
|
+
complete: vi.fn().mockResolvedValue(undefined),
|
|
294
|
+
dispose: vi.fn(),
|
|
295
|
+
};
|
|
296
|
+
setupLogin.connect.mockResolvedValue(setup);
|
|
297
|
+
return { controller, setup };
|
|
298
|
+
};
|
|
299
|
+
const authorization = {
|
|
300
|
+
deviceCode: "private-device-code",
|
|
301
|
+
userCode: "ABCD-EFGH",
|
|
302
|
+
verificationUriComplete:
|
|
303
|
+
"https://accounts.assistant-ui.com/device?user_code=ABCD-EFGH",
|
|
304
|
+
expiresAt: Date.now() + 60_000,
|
|
305
|
+
interval: 5,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
it("keeps device polling and tokens in the CLI and reports only public approval and status", async () => {
|
|
309
|
+
const config = await fixture();
|
|
310
|
+
const { setup } = bridge();
|
|
311
|
+
login.start.mockResolvedValue(authorization);
|
|
312
|
+
login.wait.mockResolvedValue(credentials);
|
|
313
|
+
const print = vi.fn();
|
|
314
|
+
await loginToCloud(config, {
|
|
315
|
+
setupUrl: "https://checkout.test/session",
|
|
316
|
+
print,
|
|
317
|
+
});
|
|
318
|
+
expect(setup.publish).toHaveBeenCalledWith(authorization, config.issuer);
|
|
319
|
+
expect(login.wait).toHaveBeenLastCalledWith(authorization, {
|
|
320
|
+
signal: expect.any(AbortSignal),
|
|
321
|
+
});
|
|
322
|
+
expect(setup.complete).toHaveBeenCalledWith("signed-in");
|
|
323
|
+
expect(setup.dispose).toHaveBeenCalledOnce();
|
|
324
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
325
|
+
expect(JSON.stringify(print.mock.calls)).not.toMatch(
|
|
326
|
+
/private-device-code|access-token|refresh-token/,
|
|
327
|
+
);
|
|
328
|
+
expect(JSON.stringify(setup.complete.mock.calls)).not.toContain("token");
|
|
329
|
+
});
|
|
330
|
+
it("refuses to save late credentials after the wizard cancels and revokes that grant", async () => {
|
|
331
|
+
const config = await fixture();
|
|
332
|
+
const { controller, setup } = bridge();
|
|
333
|
+
login.start.mockResolvedValue(authorization);
|
|
334
|
+
login.revoke.mockResolvedValue(undefined);
|
|
335
|
+
login.wait.mockImplementationOnce(async () => {
|
|
336
|
+
controller.abort(new Error("wizard cancelled"));
|
|
337
|
+
return credentials;
|
|
338
|
+
});
|
|
339
|
+
await expect(
|
|
340
|
+
loginToCloud(config, {
|
|
341
|
+
setupUrl: "https://checkout.test/session",
|
|
342
|
+
print: () => {},
|
|
343
|
+
}),
|
|
344
|
+
).rejects.toThrow("wizard cancelled");
|
|
345
|
+
expect(await readCloudCredentials(config)).toBeNull();
|
|
346
|
+
expect(login.revoke).toHaveBeenLastCalledWith(credentials);
|
|
347
|
+
expect(setup.complete).toHaveBeenCalledWith("cancelled");
|
|
348
|
+
expect(setup.dispose).toHaveBeenCalledOnce();
|
|
349
|
+
});
|
|
350
|
+
it("reports a denied grant without storing credentials or exposing the error to the wizard", async () => {
|
|
351
|
+
const config = await fixture();
|
|
352
|
+
const { setup } = bridge();
|
|
353
|
+
login.start.mockResolvedValue(authorization);
|
|
354
|
+
login.wait.mockRejectedValueOnce(new Error("access_denied"));
|
|
355
|
+
await expect(
|
|
356
|
+
loginToCloud(config, {
|
|
357
|
+
setupUrl: "https://checkout.test/session",
|
|
358
|
+
print: () => {},
|
|
359
|
+
}),
|
|
360
|
+
).rejects.toThrow("access_denied");
|
|
361
|
+
expect(await readCloudCredentials(config)).toBeNull();
|
|
362
|
+
expect(setup.complete).toHaveBeenCalledWith("failed");
|
|
363
|
+
expect(setup.dispose).toHaveBeenCalledOnce();
|
|
364
|
+
});
|
|
365
|
+
it("retains a successful local login when publishing its terminal status fails", async () => {
|
|
366
|
+
const config = await fixture();
|
|
367
|
+
const { setup } = bridge();
|
|
368
|
+
setup.complete.mockRejectedValueOnce(new Error("closed connection"));
|
|
369
|
+
login.start.mockResolvedValue(authorization);
|
|
370
|
+
login.wait.mockResolvedValueOnce(credentials);
|
|
371
|
+
const print = vi.fn();
|
|
372
|
+
expect(
|
|
373
|
+
await loginToCloud(config, {
|
|
374
|
+
setupUrl: "https://checkout.test/session",
|
|
375
|
+
print,
|
|
376
|
+
}),
|
|
377
|
+
).toEqual(credentials);
|
|
378
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
379
|
+
expect(print).toHaveBeenCalledWith(
|
|
380
|
+
"Signed in locally. The setup connection could not be updated.",
|
|
381
|
+
);
|
|
382
|
+
expect(setup.dispose).toHaveBeenCalledOnce();
|
|
383
|
+
});
|
|
384
|
+
it("does not connect to the wizard when saved credentials are already usable", async () => {
|
|
385
|
+
const config = await fixture();
|
|
386
|
+
await saveCloudCredentials(config, credentials);
|
|
387
|
+
login.ensureFresh.mockImplementationOnce(async (saved) => saved);
|
|
388
|
+
const connections = setupLogin.connect.mock.calls.length;
|
|
389
|
+
const starts = login.start.mock.calls.length;
|
|
390
|
+
expect(
|
|
391
|
+
await cloudAccessToken(config, {
|
|
392
|
+
setupUrl: "https://checkout.test/session",
|
|
393
|
+
}),
|
|
394
|
+
).toBe("access-token");
|
|
395
|
+
expect(setupLogin.connect.mock.calls.length).toBe(connections);
|
|
396
|
+
expect(login.start.mock.calls.length).toBe(starts);
|
|
397
|
+
});
|
|
398
|
+
it("cancels a stalled device request without publishing or polling its late result", async () => {
|
|
399
|
+
const config = await fixture();
|
|
400
|
+
const { controller, setup } = bridge();
|
|
401
|
+
let finish!: (value: typeof authorization) => void;
|
|
402
|
+
login.start.mockImplementationOnce(
|
|
403
|
+
() =>
|
|
404
|
+
new Promise((resolve) => {
|
|
405
|
+
finish = resolve;
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
const waits = login.wait.mock.calls.length;
|
|
409
|
+
const started = loginToCloud(config, {
|
|
410
|
+
setupUrl: "https://checkout.test/session",
|
|
411
|
+
print: () => {},
|
|
412
|
+
});
|
|
413
|
+
await vi.waitFor(() => expect(finish).toBeDefined());
|
|
414
|
+
controller.abort(new Error("wizard cancelled"));
|
|
415
|
+
await expect(started).rejects.toThrow("wizard cancelled");
|
|
416
|
+
finish(authorization);
|
|
417
|
+
await Promise.resolve();
|
|
418
|
+
expect(setup.publish).not.toHaveBeenCalled();
|
|
419
|
+
expect(login.wait.mock.calls.length).toBe(waits);
|
|
420
|
+
expect(await readCloudCredentials(config)).toBeNull();
|
|
421
|
+
expect(setup.dispose).toHaveBeenCalledOnce();
|
|
422
|
+
});
|
|
423
|
+
it("aborts startup HTTP while allowing revocation requests after cancellation", async () => {
|
|
424
|
+
const config = await fixture();
|
|
425
|
+
const { controller } = bridge();
|
|
426
|
+
let startupSignal: AbortSignal | undefined;
|
|
427
|
+
let revokeSignal: AbortSignal | null | undefined;
|
|
428
|
+
vi.stubGlobal(
|
|
429
|
+
"fetch",
|
|
430
|
+
vi.fn(async (_input, init) => {
|
|
431
|
+
if (String(_input).endsWith("/device/code")) {
|
|
432
|
+
startupSignal = init.signal;
|
|
433
|
+
return new Promise((_resolve, reject) =>
|
|
434
|
+
init.signal.addEventListener(
|
|
435
|
+
"abort",
|
|
436
|
+
() => reject(init.signal.reason),
|
|
437
|
+
{ once: true },
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
revokeSignal = init?.signal;
|
|
442
|
+
return new Response(null, { status: 200 });
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
login.start.mockImplementationOnce(() =>
|
|
446
|
+
createDeviceLogin.mock.lastCall![0].fetch(
|
|
447
|
+
`${config.issuer}/api/auth/device/code`,
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
const started = loginToCloud(config, {
|
|
451
|
+
setupUrl: "https://checkout.test/session",
|
|
452
|
+
print: () => {},
|
|
453
|
+
});
|
|
454
|
+
await vi.waitFor(() => expect(startupSignal).toBeDefined());
|
|
455
|
+
controller.abort(new Error("wizard cancelled"));
|
|
456
|
+
await expect(started).rejects.toThrow("wizard cancelled");
|
|
457
|
+
expect(startupSignal!.aborted).toBe(true);
|
|
458
|
+
await createDeviceLogin.mock.lastCall![0].fetch(
|
|
459
|
+
`${config.issuer}/api/auth/oauth2/revoke`,
|
|
460
|
+
);
|
|
461
|
+
expect(revokeSignal?.aborted).not.toBe(true);
|
|
462
|
+
});
|
|
463
|
+
it("revokes cancelled credentials after rollback restores the previous login", async () => {
|
|
464
|
+
const config = await fixture();
|
|
465
|
+
await saveCloudCredentials(config, credentials);
|
|
466
|
+
const { controller, setup } = bridge();
|
|
467
|
+
const approved = {
|
|
468
|
+
...credentials,
|
|
469
|
+
user: { ...credentials.user, id: "cancelled-login" },
|
|
470
|
+
};
|
|
471
|
+
login.start.mockResolvedValueOnce(authorization);
|
|
472
|
+
login.wait.mockResolvedValueOnce(approved);
|
|
473
|
+
login.revoke.mockResolvedValueOnce(undefined);
|
|
474
|
+
filesystem.rename = async (_from, _to, run) => {
|
|
475
|
+
await run();
|
|
476
|
+
controller.abort(new Error("wizard cancelled"));
|
|
477
|
+
};
|
|
478
|
+
await expect(
|
|
479
|
+
loginToCloud(config, {
|
|
480
|
+
setupUrl: "https://checkout.test/session",
|
|
481
|
+
print: () => {},
|
|
482
|
+
}),
|
|
483
|
+
).rejects.toThrow("wizard cancelled");
|
|
484
|
+
expect(login.revoke).toHaveBeenLastCalledWith(approved);
|
|
485
|
+
expect(await readCloudCredentials(config)).toMatchObject(credentials);
|
|
486
|
+
expect(setup.complete).toHaveBeenLastCalledWith("cancelled");
|
|
487
|
+
});
|
|
488
|
+
it("does not initiate device authorization when the setup connection fails", async () => {
|
|
489
|
+
const config = await fixture();
|
|
490
|
+
setupLogin.connect.mockRejectedValueOnce(new Error("session closed"));
|
|
491
|
+
const count = login.start.mock.calls.length;
|
|
492
|
+
await expect(
|
|
493
|
+
loginToCloud(config, {
|
|
494
|
+
setupUrl: "https://checkout.test/session",
|
|
495
|
+
print: () => {},
|
|
496
|
+
}),
|
|
497
|
+
).rejects.toThrow("session closed");
|
|
498
|
+
expect(login.start.mock.calls.length).toBe(count);
|
|
499
|
+
});
|
|
500
|
+
});
|