@vellumai/cli 0.8.11 → 0.8.12-dev.202606122337.5897832
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/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/platform-client.test.ts +107 -0
- package/src/__tests__/platform-releases.test.ts +117 -0
- package/src/__tests__/upgrade-preflight.test.ts +203 -0
- package/src/__tests__/version-compat.test.ts +31 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/login.ts +123 -59
- package/src/commands/upgrade.ts +303 -41
- package/src/commands/wake.ts +7 -5
- package/src/lib/platform-client.ts +68 -0
- package/src/lib/platform-releases.ts +125 -103
- package/src/lib/upgrade-lifecycle.ts +12 -21
- package/src/lib/upgrade-preflight.ts +127 -0
- package/src/lib/version-compat.ts +18 -0
- package/src/lib/workos-pkce.ts +160 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
fetchReleases,
|
|
5
|
+
resolveImageRefsDetailed,
|
|
6
|
+
} from "../lib/platform-releases.js";
|
|
7
|
+
|
|
8
|
+
const originalFetch = globalThis.fetch;
|
|
9
|
+
|
|
10
|
+
function mockFetchJson(body: unknown, status = 200) {
|
|
11
|
+
const fetchMock = mock(
|
|
12
|
+
async (_url: RequestInfo | URL, _init?: RequestInit) =>
|
|
13
|
+
new Response(JSON.stringify(body), { status }),
|
|
14
|
+
);
|
|
15
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
16
|
+
return fetchMock;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mockFetchError() {
|
|
20
|
+
globalThis.fetch = mock(async () => {
|
|
21
|
+
throw new TypeError("fetch failed");
|
|
22
|
+
}) as unknown as typeof globalThis.fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
process.env.VELLUM_PLATFORM_URL = "https://platform.test";
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = originalFetch;
|
|
31
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const RELEASE = {
|
|
35
|
+
version: "0.7.0",
|
|
36
|
+
assistant_image_ref: "gcr.io/vellum/assistant@sha256:aaa",
|
|
37
|
+
gateway_image_ref: "gcr.io/vellum/gateway@sha256:bbb",
|
|
38
|
+
credential_executor_image_ref: "gcr.io/vellum/ces@sha256:ccc",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("fetchReleases", () => {
|
|
42
|
+
test("defaults to stable=true and returns the list", async () => {
|
|
43
|
+
const fetchMock = mockFetchJson([RELEASE]);
|
|
44
|
+
const releases = await fetchReleases();
|
|
45
|
+
expect(releases).toEqual([RELEASE]);
|
|
46
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
47
|
+
expect(url).toContain("/v1/releases/?stable=true");
|
|
48
|
+
expect(url).toContain("limit=100");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("passes the channel param when given", async () => {
|
|
52
|
+
const fetchMock = mockFetchJson([RELEASE]);
|
|
53
|
+
await fetchReleases({ channel: "preview" });
|
|
54
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
55
|
+
expect(url).toContain("/v1/releases/?channel=preview");
|
|
56
|
+
expect(url).toContain("limit=100");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("uses the platformUrl override over the resolved default", async () => {
|
|
60
|
+
const fetchMock = mockFetchJson([RELEASE]);
|
|
61
|
+
await fetchReleases({ platformUrl: "https://other-platform.test" });
|
|
62
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
63
|
+
expect(url).toContain("https://other-platform.test/v1/releases/");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns null on non-OK response", async () => {
|
|
67
|
+
mockFetchJson({ detail: "nope" }, 500);
|
|
68
|
+
expect(await fetchReleases()).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns null on network error", async () => {
|
|
72
|
+
mockFetchError();
|
|
73
|
+
expect(await fetchReleases()).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolveImageRefsDetailed", () => {
|
|
78
|
+
test("returns platform refs when the version is found", async () => {
|
|
79
|
+
mockFetchJson([RELEASE]);
|
|
80
|
+
const result = await resolveImageRefsDetailed("v0.7.0");
|
|
81
|
+
expect(result.status).toBe("platform");
|
|
82
|
+
if (result.status === "platform") {
|
|
83
|
+
expect(result.imageTags.assistant).toBe(RELEASE.assistant_image_ref);
|
|
84
|
+
expect(result.imageTags.gateway).toBe(RELEASE.gateway_image_ref);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("falls back to DockerHub for a null credential-executor ref", async () => {
|
|
89
|
+
mockFetchJson([{ ...RELEASE, credential_executor_image_ref: null }]);
|
|
90
|
+
const result = await resolveImageRefsDetailed("0.7.0");
|
|
91
|
+
expect(result.status).toBe("platform");
|
|
92
|
+
if (result.status === "platform") {
|
|
93
|
+
expect(result.imageTags["credential-executor"]).toContain(":0.7.0");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("returns version-not-found when the platform list lacks the version", async () => {
|
|
98
|
+
mockFetchJson([RELEASE]);
|
|
99
|
+
const result = await resolveImageRefsDetailed("v9.9.9");
|
|
100
|
+
expect(result.status).toBe("version-not-found");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("returns dockerhub-fallback when the platform is unreachable", async () => {
|
|
104
|
+
mockFetchError();
|
|
105
|
+
const result = await resolveImageRefsDetailed("v0.7.0");
|
|
106
|
+
expect(result.status).toBe("dockerhub-fallback");
|
|
107
|
+
if (result.status === "dockerhub-fallback") {
|
|
108
|
+
expect(result.imageTags.assistant).toContain(":v0.7.0");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns dockerhub-fallback when required refs are missing", async () => {
|
|
113
|
+
mockFetchJson([{ ...RELEASE, assistant_image_ref: null }]);
|
|
114
|
+
const result = await resolveImageRefsDetailed("0.7.0");
|
|
115
|
+
expect(result.status).toBe("dockerhub-fallback");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
evaluateUpgradePoll,
|
|
5
|
+
resolveUpgradeTarget,
|
|
6
|
+
} from "../lib/upgrade-preflight.js";
|
|
7
|
+
|
|
8
|
+
const RELEASES = [
|
|
9
|
+
{ version: "0.8.0", is_stable: false },
|
|
10
|
+
{ version: "0.7.0" },
|
|
11
|
+
{ version: "0.6.0" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("resolveUpgradeTarget", () => {
|
|
15
|
+
test("explicit version present in releases resolves ok", () => {
|
|
16
|
+
const result = resolveUpgradeTarget({
|
|
17
|
+
explicitVersion: "v0.7.0",
|
|
18
|
+
releases: RELEASES,
|
|
19
|
+
currentVersion: "0.6.0",
|
|
20
|
+
});
|
|
21
|
+
expect(result.kind).toBe("ok");
|
|
22
|
+
expect(result.target).toBe("v0.7.0");
|
|
23
|
+
expect(result.isNoOp).toBe(false);
|
|
24
|
+
expect(result.isDowngrade).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("explicit version absent from releases is version-not-found", () => {
|
|
28
|
+
const result = resolveUpgradeTarget({
|
|
29
|
+
explicitVersion: "v9.9.9",
|
|
30
|
+
releases: RELEASES,
|
|
31
|
+
currentVersion: "0.6.0",
|
|
32
|
+
});
|
|
33
|
+
expect(result.kind).toBe("version-not-found");
|
|
34
|
+
expect(result.target).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("explicit version is trusted when releases are unavailable", () => {
|
|
38
|
+
const result = resolveUpgradeTarget({
|
|
39
|
+
explicitVersion: "v0.7.0",
|
|
40
|
+
releases: null,
|
|
41
|
+
currentVersion: "0.6.0",
|
|
42
|
+
});
|
|
43
|
+
expect(result.kind).toBe("ok");
|
|
44
|
+
expect(result.target).toBe("v0.7.0");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("default target skips non-stable heads", () => {
|
|
48
|
+
const result = resolveUpgradeTarget({
|
|
49
|
+
explicitVersion: null,
|
|
50
|
+
releases: RELEASES,
|
|
51
|
+
currentVersion: "0.6.0",
|
|
52
|
+
});
|
|
53
|
+
expect(result.kind).toBe("ok");
|
|
54
|
+
expect(result.target).toBe("0.7.0");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("default target falls back to first release when none are stable", () => {
|
|
58
|
+
const result = resolveUpgradeTarget({
|
|
59
|
+
explicitVersion: null,
|
|
60
|
+
releases: [{ version: "0.8.0", is_stable: false }],
|
|
61
|
+
currentVersion: "0.6.0",
|
|
62
|
+
});
|
|
63
|
+
expect(result.target).toBe("0.8.0");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("no explicit version with unreachable releases is no-releases", () => {
|
|
67
|
+
const result = resolveUpgradeTarget({
|
|
68
|
+
explicitVersion: null,
|
|
69
|
+
releases: null,
|
|
70
|
+
currentVersion: "0.6.0",
|
|
71
|
+
});
|
|
72
|
+
expect(result.kind).toBe("no-releases");
|
|
73
|
+
expect(result.target).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("no explicit version with empty releases is no-releases", () => {
|
|
77
|
+
const result = resolveUpgradeTarget({
|
|
78
|
+
explicitVersion: null,
|
|
79
|
+
releases: [],
|
|
80
|
+
currentVersion: "0.6.0",
|
|
81
|
+
});
|
|
82
|
+
expect(result.kind).toBe("no-releases");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("detects no-op across v prefix", () => {
|
|
86
|
+
const result = resolveUpgradeTarget({
|
|
87
|
+
explicitVersion: "v0.7.0",
|
|
88
|
+
releases: RELEASES,
|
|
89
|
+
currentVersion: "0.7.0",
|
|
90
|
+
});
|
|
91
|
+
expect(result.isNoOp).toBe(true);
|
|
92
|
+
expect(result.isDowngrade).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("detects downgrade", () => {
|
|
96
|
+
const result = resolveUpgradeTarget({
|
|
97
|
+
explicitVersion: "v0.6.0",
|
|
98
|
+
releases: RELEASES,
|
|
99
|
+
currentVersion: "0.7.0",
|
|
100
|
+
});
|
|
101
|
+
expect(result.isDowngrade).toBe(true);
|
|
102
|
+
expect(result.isNoOp).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("unknown current version yields no flags", () => {
|
|
106
|
+
const result = resolveUpgradeTarget({
|
|
107
|
+
explicitVersion: "v0.7.0",
|
|
108
|
+
releases: RELEASES,
|
|
109
|
+
currentVersion: undefined,
|
|
110
|
+
});
|
|
111
|
+
expect(result.comparison).toBeNull();
|
|
112
|
+
expect(result.isNoOp).toBe(false);
|
|
113
|
+
expect(result.isDowngrade).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("evaluateUpgradePoll", () => {
|
|
118
|
+
test("known target completes on version match", () => {
|
|
119
|
+
expect(
|
|
120
|
+
evaluateUpgradePoll({
|
|
121
|
+
targetVersion: "v0.7.0",
|
|
122
|
+
initialVersion: "0.6.0",
|
|
123
|
+
observedVersion: "0.7.0",
|
|
124
|
+
inProgress: null,
|
|
125
|
+
sawInProgress: false,
|
|
126
|
+
}),
|
|
127
|
+
).toBe("complete");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("known target pends until the version matches", () => {
|
|
131
|
+
expect(
|
|
132
|
+
evaluateUpgradePoll({
|
|
133
|
+
targetVersion: "0.7.0",
|
|
134
|
+
initialVersion: "0.6.0",
|
|
135
|
+
observedVersion: "0.6.0",
|
|
136
|
+
inProgress: false,
|
|
137
|
+
sawInProgress: false,
|
|
138
|
+
}),
|
|
139
|
+
).toBe("pending");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("unknown target completes when the in-progress lock releases", () => {
|
|
143
|
+
expect(
|
|
144
|
+
evaluateUpgradePoll({
|
|
145
|
+
targetVersion: null,
|
|
146
|
+
initialVersion: "0.6.0",
|
|
147
|
+
observedVersion: "0.6.0",
|
|
148
|
+
inProgress: false,
|
|
149
|
+
sawInProgress: true,
|
|
150
|
+
}),
|
|
151
|
+
).toBe("complete");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("unknown target pends while the lock was never observed", () => {
|
|
155
|
+
expect(
|
|
156
|
+
evaluateUpgradePoll({
|
|
157
|
+
targetVersion: null,
|
|
158
|
+
initialVersion: "0.6.0",
|
|
159
|
+
observedVersion: "0.6.0",
|
|
160
|
+
inProgress: false,
|
|
161
|
+
sawInProgress: false,
|
|
162
|
+
}),
|
|
163
|
+
).toBe("pending");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("unknown target without upgrade-status completes on version change", () => {
|
|
167
|
+
expect(
|
|
168
|
+
evaluateUpgradePoll({
|
|
169
|
+
targetVersion: null,
|
|
170
|
+
initialVersion: "0.6.0",
|
|
171
|
+
observedVersion: "0.7.0",
|
|
172
|
+
inProgress: null,
|
|
173
|
+
sawInProgress: false,
|
|
174
|
+
}),
|
|
175
|
+
).toBe("complete");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("unknown target completes on version change even when the lock was never observed", () => {
|
|
179
|
+
// Upgrade finished before the first poll: in_progress already false,
|
|
180
|
+
// sawInProgress never set — the version change alone must complete.
|
|
181
|
+
expect(
|
|
182
|
+
evaluateUpgradePoll({
|
|
183
|
+
targetVersion: null,
|
|
184
|
+
initialVersion: "0.6.0",
|
|
185
|
+
observedVersion: "0.7.0",
|
|
186
|
+
inProgress: false,
|
|
187
|
+
sawInProgress: false,
|
|
188
|
+
}),
|
|
189
|
+
).toBe("complete");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("unknown target without upgrade-status pends while the version is unchanged", () => {
|
|
193
|
+
expect(
|
|
194
|
+
evaluateUpgradePoll({
|
|
195
|
+
targetVersion: null,
|
|
196
|
+
initialVersion: "0.6.0",
|
|
197
|
+
observedVersion: "v0.6.0",
|
|
198
|
+
inProgress: null,
|
|
199
|
+
sawInProgress: false,
|
|
200
|
+
}),
|
|
201
|
+
).toBe("pending");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
parseVersion,
|
|
5
5
|
compareVersions,
|
|
6
6
|
isVersionCompatible,
|
|
7
|
+
stripVersionPrefix,
|
|
8
|
+
versionsEqual,
|
|
7
9
|
} from "../lib/version-compat.js";
|
|
8
10
|
|
|
9
11
|
describe("parseVersion", () => {
|
|
@@ -204,3 +206,32 @@ describe("isVersionCompatible", () => {
|
|
|
204
206
|
expect(isVersionCompatible("bad", "1.0.0")).toBe(false);
|
|
205
207
|
});
|
|
206
208
|
});
|
|
209
|
+
|
|
210
|
+
describe("stripVersionPrefix", () => {
|
|
211
|
+
test("strips v prefix", () => {
|
|
212
|
+
expect(stripVersionPrefix("v0.7.0")).toBe("0.7.0");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("strips V prefix", () => {
|
|
216
|
+
expect(stripVersionPrefix("V0.7.0")).toBe("0.7.0");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("leaves unprefixed versions unchanged", () => {
|
|
220
|
+
expect(stripVersionPrefix("0.7.0")).toBe("0.7.0");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("versionsEqual", () => {
|
|
225
|
+
test("equal across v prefix", () => {
|
|
226
|
+
expect(versionsEqual("v0.7.0", "0.7.0")).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("different versions are not equal", () => {
|
|
230
|
+
expect(versionsEqual("0.7.0", "0.7.1")).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("unparseable falls back to prefix-stripped string equality", () => {
|
|
234
|
+
expect(versionsEqual("vweird-build", "weird-build")).toBe(true);
|
|
235
|
+
expect(versionsEqual("weird-build", "other-build")).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -272,12 +272,23 @@ describe("vellum wake", () => {
|
|
|
272
272
|
expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
test("
|
|
275
|
+
test("re-provisions even when a guardian token already exists", async () => {
|
|
276
|
+
// A connect can 401 off a token whose local state looks healthy
|
|
277
|
+
// (revoked, mis-seeded, wrong principal). The user explicitly confirmed
|
|
278
|
+
// the destructive repair, so the flag forces a re-lease instead of
|
|
279
|
+
// guessing from local token state and recreating the no-op loop.
|
|
276
280
|
process.argv = ["bun", "vellum", "wake", "--repair-guardian", "local-assistant"];
|
|
277
|
-
// loadGuardianToken returns a token by default
|
|
281
|
+
// loadGuardianToken returns a healthy-looking token by default.
|
|
278
282
|
await wake();
|
|
279
283
|
|
|
280
|
-
expect(resetGuardianBootstrapMock).
|
|
281
|
-
|
|
284
|
+
expect(resetGuardianBootstrapMock).toHaveBeenCalledWith(
|
|
285
|
+
"http://127.0.0.1:7830",
|
|
286
|
+
"generated-bootstrap-secret",
|
|
287
|
+
);
|
|
288
|
+
expect(leaseGuardianTokenMock).toHaveBeenCalledWith(
|
|
289
|
+
"http://127.0.0.1:7830",
|
|
290
|
+
"local-assistant",
|
|
291
|
+
"generated-bootstrap-secret",
|
|
292
|
+
);
|
|
282
293
|
});
|
|
283
294
|
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildAuthorizeUrl,
|
|
5
|
+
exchangeAccessTokenForSession,
|
|
6
|
+
exchangeCodeWithWorkos,
|
|
7
|
+
fetchWorkosClientId,
|
|
8
|
+
generatePkcePair,
|
|
9
|
+
selectWorkosClientId,
|
|
10
|
+
} from "../lib/workos-pkce.js";
|
|
11
|
+
|
|
12
|
+
const originalFetch = globalThis.fetch;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
globalThis.fetch = originalFetch;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Route fetch responses by URL substring so each WorkOS/platform call can be
|
|
20
|
+
* inspected. The module talks to remote hosts via loopbackSafeFetch, which
|
|
21
|
+
* delegates to globalThis.fetch.
|
|
22
|
+
*/
|
|
23
|
+
function mockFetchByUrl(responses: Record<string, () => Response>) {
|
|
24
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
25
|
+
globalThis.fetch = mock(
|
|
26
|
+
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
27
|
+
const url = String(input);
|
|
28
|
+
calls.push({ url, init });
|
|
29
|
+
const match = Object.entries(responses).find(([prefix]) =>
|
|
30
|
+
url.includes(prefix),
|
|
31
|
+
);
|
|
32
|
+
if (!match) throw new Error(`Unexpected fetch: ${url}`);
|
|
33
|
+
return match[1]();
|
|
34
|
+
},
|
|
35
|
+
) as unknown as typeof globalThis.fetch;
|
|
36
|
+
return calls;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("generatePkcePair", () => {
|
|
40
|
+
test("challenge is the base64url sha256 of the verifier", async () => {
|
|
41
|
+
const { verifier, challenge } = generatePkcePair();
|
|
42
|
+
const digest = await crypto.subtle.digest(
|
|
43
|
+
"SHA-256",
|
|
44
|
+
new TextEncoder().encode(verifier),
|
|
45
|
+
);
|
|
46
|
+
const expected = Buffer.from(digest).toString("base64url");
|
|
47
|
+
expect(challenge).toBe(expected);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("verifiers are unique", () => {
|
|
51
|
+
expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("buildAuthorizeUrl", () => {
|
|
56
|
+
const base = {
|
|
57
|
+
clientId: "client_123",
|
|
58
|
+
redirectUri: "http://127.0.0.1:4242/auth/callback",
|
|
59
|
+
challenge: "chal",
|
|
60
|
+
state: "st",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test("targets user_management/authorize with PKCE and authkit defaults", () => {
|
|
64
|
+
const url = new URL(buildAuthorizeUrl(base));
|
|
65
|
+
expect(url.origin).toBe("https://api.workos.com");
|
|
66
|
+
expect(url.pathname).toBe("/user_management/authorize");
|
|
67
|
+
expect(url.searchParams.get("client_id")).toBe("client_123");
|
|
68
|
+
expect(url.searchParams.get("redirect_uri")).toBe(base.redirectUri);
|
|
69
|
+
expect(url.searchParams.get("response_type")).toBe("code");
|
|
70
|
+
expect(url.searchParams.get("scope")).toBe("openid profile email");
|
|
71
|
+
expect(url.searchParams.get("code_challenge")).toBe("chal");
|
|
72
|
+
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|
73
|
+
expect(url.searchParams.get("state")).toBe("st");
|
|
74
|
+
expect(url.searchParams.get("provider")).toBe("authkit");
|
|
75
|
+
// Session reuse: never force a fresh IdP login.
|
|
76
|
+
expect(url.searchParams.has("prompt")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("provider hint replaces authkit", () => {
|
|
80
|
+
const url = new URL(
|
|
81
|
+
buildAuthorizeUrl({ ...base, providerHint: "GoogleOAuth" }),
|
|
82
|
+
);
|
|
83
|
+
expect(url.searchParams.get("provider")).toBe("GoogleOAuth");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("login hint is forwarded", () => {
|
|
87
|
+
// generic-examples:ignore-next-line — reason: test fixture for URL encoding, not a real email
|
|
88
|
+
const url = new URL(buildAuthorizeUrl({ ...base, loginHint: "a@b.co" }));
|
|
89
|
+
// generic-examples:ignore-next-line — reason: test fixture for URL encoding, not a real email
|
|
90
|
+
expect(url.searchParams.get("login_hint")).toBe("a@b.co");
|
|
91
|
+
|
|
92
|
+
const noHint = new URL(buildAuthorizeUrl(base));
|
|
93
|
+
expect(noHint.searchParams.has("login_hint")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("selectWorkosClientId", () => {
|
|
98
|
+
// During coexistence the platform lists two providers that share the
|
|
99
|
+
// "workos-oidc" id; only the OAuth2 one (no discovery URL) is usable.
|
|
100
|
+
const legacy = {
|
|
101
|
+
id: "workos-oidc",
|
|
102
|
+
name: "WorkOS OIDC",
|
|
103
|
+
client_id: "client_connect",
|
|
104
|
+
flows: ["provider_redirect", "provider_token"],
|
|
105
|
+
openid_configuration_url:
|
|
106
|
+
"https://x.authkit.app/.well-known/openid-configuration",
|
|
107
|
+
};
|
|
108
|
+
const modern = {
|
|
109
|
+
id: "workos-oidc",
|
|
110
|
+
name: "WorkOS",
|
|
111
|
+
client_id: "client_um",
|
|
112
|
+
flows: ["provider_redirect", "provider_token"],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
test("picks the OAuth2 entry during coexistence", () => {
|
|
116
|
+
expect(selectWorkosClientId([legacy, modern])).toBe("client_um");
|
|
117
|
+
expect(selectWorkosClientId([modern, legacy])).toBe("client_um");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns null when the platform lacks token auth", () => {
|
|
121
|
+
const preTokenAuth = { ...modern, flows: ["provider_redirect"] };
|
|
122
|
+
expect(selectWorkosClientId([legacy, preTokenAuth])).toBeNull();
|
|
123
|
+
expect(selectWorkosClientId([])).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("ignores an entry missing client_id", () => {
|
|
127
|
+
const noClientId = { id: "workos-oidc", flows: ["provider_token"] };
|
|
128
|
+
expect(selectWorkosClientId([noClientId])).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("fetchWorkosClientId", () => {
|
|
133
|
+
test("resolves the client id from the headless config", async () => {
|
|
134
|
+
const calls = mockFetchByUrl({
|
|
135
|
+
"/_allauth/app/v1/config": () =>
|
|
136
|
+
new Response(
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
data: {
|
|
139
|
+
socialaccount: {
|
|
140
|
+
providers: [
|
|
141
|
+
{
|
|
142
|
+
id: "workos-oidc",
|
|
143
|
+
client_id: "client_um",
|
|
144
|
+
flows: ["provider_token"],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
{ status: 200 },
|
|
151
|
+
),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(await fetchWorkosClientId("https://platform.example")).toBe(
|
|
155
|
+
"client_um",
|
|
156
|
+
);
|
|
157
|
+
expect(calls[0]!.url).toBe(
|
|
158
|
+
"https://platform.example/_allauth/app/v1/config",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("derives the config URL from the origin, ignoring any path", async () => {
|
|
163
|
+
const calls = mockFetchByUrl({
|
|
164
|
+
"/_allauth/app/v1/config": () =>
|
|
165
|
+
new Response(
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
data: {
|
|
168
|
+
socialaccount: {
|
|
169
|
+
providers: [
|
|
170
|
+
{ client_id: "client_um", flows: ["provider_token"] },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
{ status: 200 },
|
|
176
|
+
),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await fetchWorkosClientId("https://platform.example/some/path");
|
|
180
|
+
expect(calls[0]!.url).toBe(
|
|
181
|
+
"https://platform.example/_allauth/app/v1/config",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("throws a clear error when no token-auth provider is advertised", async () => {
|
|
186
|
+
mockFetchByUrl({
|
|
187
|
+
"/_allauth/app/v1/config": () =>
|
|
188
|
+
new Response(
|
|
189
|
+
JSON.stringify({ data: { socialaccount: { providers: [] } } }),
|
|
190
|
+
{ status: 200 },
|
|
191
|
+
),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await expect(
|
|
195
|
+
fetchWorkosClientId("https://platform.example"),
|
|
196
|
+
).rejects.toThrow(/does not advertise/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("throws on a non-OK config response", async () => {
|
|
200
|
+
mockFetchByUrl({
|
|
201
|
+
"/_allauth/app/v1/config": () => new Response("nope", { status: 500 }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await expect(
|
|
205
|
+
fetchWorkosClientId("https://platform.example"),
|
|
206
|
+
).rejects.toThrow(/500/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("exchangeCodeWithWorkos", () => {
|
|
211
|
+
test("posts a public-client PKCE exchange and returns the access token", async () => {
|
|
212
|
+
const calls = mockFetchByUrl({
|
|
213
|
+
"/user_management/authenticate": () =>
|
|
214
|
+
new Response(JSON.stringify({ access_token: "at_1", user: {} }), {
|
|
215
|
+
status: 200,
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const token = await exchangeCodeWithWorkos({
|
|
220
|
+
clientId: "client_um",
|
|
221
|
+
code: "c",
|
|
222
|
+
verifier: "v",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(token).toBe("at_1");
|
|
226
|
+
expect(calls[0]!.url).toBe(
|
|
227
|
+
"https://api.workos.com/user_management/authenticate",
|
|
228
|
+
);
|
|
229
|
+
const body = JSON.parse(String(calls[0]!.init?.body));
|
|
230
|
+
// Public client: no secret, no API key.
|
|
231
|
+
expect(body).toEqual({
|
|
232
|
+
client_id: "client_um",
|
|
233
|
+
grant_type: "authorization_code",
|
|
234
|
+
code: "c",
|
|
235
|
+
code_verifier: "v",
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("throws with upstream detail on failure", async () => {
|
|
240
|
+
mockFetchByUrl({
|
|
241
|
+
"/user_management/authenticate": () =>
|
|
242
|
+
new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
243
|
+
status: 400,
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await expect(
|
|
248
|
+
exchangeCodeWithWorkos({ clientId: "c", code: "x", verifier: "v" }),
|
|
249
|
+
).rejects.toThrow(/400/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("throws when the exchange returns no access token", async () => {
|
|
253
|
+
mockFetchByUrl({
|
|
254
|
+
"/user_management/authenticate": () =>
|
|
255
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await expect(
|
|
259
|
+
exchangeCodeWithWorkos({ clientId: "c", code: "x", verifier: "v" }),
|
|
260
|
+
).rejects.toThrow(/no access token/);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("exchangeAccessTokenForSession", () => {
|
|
265
|
+
test("posts the headless token payload and returns the session token", async () => {
|
|
266
|
+
const calls = mockFetchByUrl({
|
|
267
|
+
"/_allauth/app/v1/auth/provider/token": () =>
|
|
268
|
+
new Response(JSON.stringify({ meta: { session_token: "sess_1" } }), {
|
|
269
|
+
status: 200,
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const token = await exchangeAccessTokenForSession(
|
|
274
|
+
"https://platform.example",
|
|
275
|
+
"client_um",
|
|
276
|
+
"at_1",
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(token).toBe("sess_1");
|
|
280
|
+
expect(calls[0]!.url).toBe(
|
|
281
|
+
"https://platform.example/_allauth/app/v1/auth/provider/token",
|
|
282
|
+
);
|
|
283
|
+
const body = JSON.parse(String(calls[0]!.init?.body));
|
|
284
|
+
expect(body).toEqual({
|
|
285
|
+
provider: "workos",
|
|
286
|
+
process: "login",
|
|
287
|
+
token: { client_id: "client_um", access_token: "at_1" },
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("throws on a rejected token", async () => {
|
|
292
|
+
mockFetchByUrl({
|
|
293
|
+
"/_allauth/app/v1/auth/provider/token": () =>
|
|
294
|
+
new Response(JSON.stringify({ errors: [{ code: "invalid_token" }] }), {
|
|
295
|
+
status: 400,
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await expect(
|
|
300
|
+
exchangeAccessTokenForSession("https://platform.example", "c", "bad"),
|
|
301
|
+
).rejects.toThrow(/400/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("throws when the session exchange returns no session token", async () => {
|
|
305
|
+
mockFetchByUrl({
|
|
306
|
+
"/_allauth/app/v1/auth/provider/token": () =>
|
|
307
|
+
new Response(JSON.stringify({ meta: {} }), { status: 200 }),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await expect(
|
|
311
|
+
exchangeAccessTokenForSession("https://platform.example", "c", "at"),
|
|
312
|
+
).rejects.toThrow(/no session token/);
|
|
313
|
+
});
|
|
314
|
+
});
|