@openparachute/hub 0.5.11-rc.1 → 0.5.12-rc.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.11-rc.1",
3
+ "version": "0.5.12-rc.2",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Tests for `/api/settings/hub-origin` (hub#298).
3
+ *
4
+ * Covers:
5
+ * - GET response shape (hub_origin + resolved_issuer + source)
6
+ * - PUT validation (URL shape, scheme, hostname, trailing slash,
7
+ * path/query/fragment rejection)
8
+ * - PUT clear (null) reverts to env/request precedence
9
+ * - Auth gating: 401 missing/empty bearer, 403 wrong scope
10
+ * - "Change takes effect on the next request" — the GET issuer
11
+ * reflects the value just written, without restarting.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import {
18
+ API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE,
19
+ handleApiSettingsHubOrigin,
20
+ validateHubOrigin,
21
+ } from "../api-settings-hub-origin.ts";
22
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
23
+ import { resolveIssuer, resolveIssuerSource } from "../hub-server.ts";
24
+ import { getHubOrigin, setHubOrigin } from "../hub-settings.ts";
25
+ import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
26
+ import { rotateSigningKey } from "../signing-keys.ts";
27
+ import { createUser } from "../users.ts";
28
+
29
+ const ISSUER = "http://127.0.0.1:1939";
30
+
31
+ interface Harness {
32
+ dir: string;
33
+ db: ReturnType<typeof openHubDb>;
34
+ userId: string;
35
+ cleanup: () => void;
36
+ }
37
+
38
+ async function makeHarness(): Promise<Harness> {
39
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-settings-hub-origin-"));
40
+ const db = openHubDb(hubDbPath(dir));
41
+ rotateSigningKey(db);
42
+ const user = await createUser(db, "owner", "pw");
43
+ return {
44
+ dir,
45
+ db,
46
+ userId: user.id,
47
+ cleanup: () => {
48
+ db.close();
49
+ rmSync(dir, { recursive: true, force: true });
50
+ },
51
+ };
52
+ }
53
+
54
+ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
55
+ const signed = await signAccessToken(h.db, {
56
+ sub: h.userId,
57
+ scopes,
58
+ audience: "parachute-hub",
59
+ clientId: "parachute-hub",
60
+ issuer: ISSUER,
61
+ ttlSeconds: 3600,
62
+ });
63
+ recordTokenMint(h.db, {
64
+ jti: signed.jti,
65
+ createdVia: "operator_mint",
66
+ subject: h.userId,
67
+ clientId: "parachute-hub",
68
+ scopes,
69
+ expiresAt: signed.expiresAt,
70
+ });
71
+ return signed.token;
72
+ }
73
+
74
+ function getReq(headers: Record<string, string> = {}): Request {
75
+ return new Request("http://localhost/api/settings/hub-origin", { method: "GET", headers });
76
+ }
77
+
78
+ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
79
+ return new Request("http://localhost/api/settings/hub-origin", {
80
+ method: "PUT",
81
+ headers: { "content-type": "application/json", ...headers },
82
+ body: typeof body === "string" ? body : JSON.stringify(body),
83
+ });
84
+ }
85
+
86
+ function deps(
87
+ h: Harness,
88
+ overrides: Partial<Parameters<typeof handleApiSettingsHubOrigin>[1]> = {},
89
+ ) {
90
+ return {
91
+ db: h.db,
92
+ issuer: ISSUER,
93
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
94
+ resolvedSource: resolveIssuerSource(h.db, undefined),
95
+ ...overrides,
96
+ };
97
+ }
98
+
99
+ describe("validateHubOrigin — pure validator", () => {
100
+ test("null → normalized null (clear)", () => {
101
+ expect(validateHubOrigin(null)).toEqual({ ok: true, normalized: null });
102
+ });
103
+
104
+ test("empty string → normalized null (footgun guard)", () => {
105
+ expect(validateHubOrigin("")).toEqual({ ok: true, normalized: null });
106
+ });
107
+
108
+ test("valid https URL → normalized verbatim", () => {
109
+ const result = validateHubOrigin("https://hub.example.com");
110
+ expect(result).toEqual({ ok: true, normalized: "https://hub.example.com" });
111
+ });
112
+
113
+ test("valid http URL (loopback dev shape) → normalized verbatim", () => {
114
+ const result = validateHubOrigin("http://127.0.0.1:1939");
115
+ expect(result).toEqual({ ok: true, normalized: "http://127.0.0.1:1939" });
116
+ });
117
+
118
+ test("rejects trailing slash", () => {
119
+ const result = validateHubOrigin("https://hub.example.com/");
120
+ expect(result.ok).toBe(false);
121
+ });
122
+
123
+ test("rejects ftp scheme", () => {
124
+ const result = validateHubOrigin("ftp://hub.example.com");
125
+ expect(result.ok).toBe(false);
126
+ if (!result.ok) expect(result.description).toMatch(/scheme/);
127
+ });
128
+
129
+ test("rejects file: scheme", () => {
130
+ const result = validateHubOrigin("file:///etc/passwd");
131
+ expect(result.ok).toBe(false);
132
+ });
133
+
134
+ test("rejects malformed URL", () => {
135
+ const result = validateHubOrigin("not-a-url");
136
+ expect(result.ok).toBe(false);
137
+ });
138
+
139
+ test("rejects URL with path component", () => {
140
+ const result = validateHubOrigin("https://hub.example.com/path");
141
+ expect(result.ok).toBe(false);
142
+ if (!result.ok) expect(result.description).toMatch(/path/);
143
+ });
144
+
145
+ test("rejects URL with query", () => {
146
+ const result = validateHubOrigin("https://hub.example.com?q=1");
147
+ expect(result.ok).toBe(false);
148
+ });
149
+
150
+ test("rejects URL with fragment", () => {
151
+ const result = validateHubOrigin("https://hub.example.com#frag");
152
+ expect(result.ok).toBe(false);
153
+ });
154
+
155
+ test("rejects URL with embedded user:pass credentials", () => {
156
+ // The normalization step re-stringifies as `protocol + "//" + host`,
157
+ // which silently strips a user:pass component — an operator who
158
+ // typos credentials in would have them invisibly dropped. Reject
159
+ // hard so the footgun surfaces.
160
+ const result = validateHubOrigin("https://user:pass@host.example.com");
161
+ expect(result.ok).toBe(false);
162
+ if (!result.ok) expect(result.description).toMatch(/credentials/);
163
+ });
164
+
165
+ test("rejects URL with embedded username only", () => {
166
+ const result = validateHubOrigin("https://user@host.example.com");
167
+ expect(result.ok).toBe(false);
168
+ if (!result.ok) expect(result.description).toMatch(/credentials/);
169
+ });
170
+
171
+ test("rejects non-string non-null types", () => {
172
+ expect(validateHubOrigin(42).ok).toBe(false);
173
+ expect(validateHubOrigin(true).ok).toBe(false);
174
+ expect(validateHubOrigin({}).ok).toBe(false);
175
+ expect(validateHubOrigin(undefined).ok).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe("auth gating", () => {
180
+ let h: Harness;
181
+ beforeEach(async () => {
182
+ h = await makeHarness();
183
+ });
184
+ afterEach(() => h.cleanup());
185
+
186
+ test("405 on non-GET/PUT methods", async () => {
187
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
188
+ const res = await handleApiSettingsHubOrigin(
189
+ new Request("http://localhost/api/settings/hub-origin", {
190
+ method: "POST",
191
+ headers: { authorization: `Bearer ${bearer}` },
192
+ }),
193
+ deps(h),
194
+ );
195
+ expect(res.status).toBe(405);
196
+ });
197
+
198
+ test("401 on missing bearer (GET)", async () => {
199
+ const res = await handleApiSettingsHubOrigin(getReq(), deps(h));
200
+ expect(res.status).toBe(401);
201
+ });
202
+
203
+ test("401 on missing bearer (PUT)", async () => {
204
+ const res = await handleApiSettingsHubOrigin(
205
+ putReq({ hub_origin: "https://hub.example.com" }),
206
+ deps(h),
207
+ );
208
+ expect(res.status).toBe(401);
209
+ });
210
+
211
+ test("401 on empty bearer value", async () => {
212
+ const res = await handleApiSettingsHubOrigin(getReq({ authorization: "Bearer " }), deps(h));
213
+ expect(res.status).toBe(401);
214
+ });
215
+
216
+ test("403 on bearer without parachute:host:admin", async () => {
217
+ // host:auth reads catalogs but must NOT flip the canonical hub URL.
218
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
219
+ const resGet = await handleApiSettingsHubOrigin(
220
+ getReq({ authorization: `Bearer ${bearer}` }),
221
+ deps(h),
222
+ );
223
+ expect(resGet.status).toBe(403);
224
+ const resPut = await handleApiSettingsHubOrigin(
225
+ putReq({ hub_origin: "https://hub.example.com" }, { authorization: `Bearer ${bearer}` }),
226
+ deps(h),
227
+ );
228
+ expect(resPut.status).toBe(403);
229
+ });
230
+ });
231
+
232
+ describe("GET /api/settings/hub-origin", () => {
233
+ let h: Harness;
234
+ beforeEach(async () => {
235
+ h = await makeHarness();
236
+ });
237
+ afterEach(() => h.cleanup());
238
+
239
+ test("returns null + request source when nothing is configured", async () => {
240
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
241
+ const res = await handleApiSettingsHubOrigin(
242
+ getReq({ authorization: `Bearer ${bearer}` }),
243
+ deps(h),
244
+ );
245
+ expect(res.status).toBe(200);
246
+ const body = (await res.json()) as {
247
+ hub_origin: string | null;
248
+ resolved_issuer: string;
249
+ source: string;
250
+ };
251
+ expect(body.hub_origin).toBeNull();
252
+ expect(body.source).toBe("request");
253
+ expect(body.resolved_issuer).toBe("http://localhost"); // request origin from getReq()
254
+ });
255
+
256
+ test("returns env source + env-resolved issuer when configuredIssuer is set", async () => {
257
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
258
+ const res = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
259
+ db: h.db,
260
+ issuer: ISSUER,
261
+ resolvedIssuer: "https://hub.from-env.example",
262
+ resolvedSource: "env",
263
+ });
264
+ const body = (await res.json()) as {
265
+ hub_origin: string | null;
266
+ resolved_issuer: string;
267
+ source: string;
268
+ };
269
+ expect(body.hub_origin).toBeNull();
270
+ expect(body.resolved_issuer).toBe("https://hub.from-env.example");
271
+ expect(body.source).toBe("env");
272
+ });
273
+
274
+ test("returns settings source when hub_origin row is set", async () => {
275
+ setHubOrigin(h.db, "https://hub.example.com");
276
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
277
+ const res = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
278
+ db: h.db,
279
+ issuer: ISSUER,
280
+ resolvedIssuer: "https://hub.example.com",
281
+ resolvedSource: "settings",
282
+ });
283
+ const body = (await res.json()) as {
284
+ hub_origin: string | null;
285
+ resolved_issuer: string;
286
+ source: string;
287
+ };
288
+ expect(body.hub_origin).toBe("https://hub.example.com");
289
+ expect(body.resolved_issuer).toBe("https://hub.example.com");
290
+ expect(body.source).toBe("settings");
291
+ });
292
+ });
293
+
294
+ describe("PUT /api/settings/hub-origin", () => {
295
+ let h: Harness;
296
+ beforeEach(async () => {
297
+ h = await makeHarness();
298
+ });
299
+ afterEach(() => h.cleanup());
300
+
301
+ test("400 on non-JSON body", async () => {
302
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
303
+ const res = await handleApiSettingsHubOrigin(
304
+ new Request("http://localhost/api/settings/hub-origin", {
305
+ method: "PUT",
306
+ headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
307
+ body: "not json",
308
+ }),
309
+ deps(h),
310
+ );
311
+ expect(res.status).toBe(400);
312
+ });
313
+
314
+ test("400 on missing hub_origin field", async () => {
315
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
316
+ const res = await handleApiSettingsHubOrigin(
317
+ putReq({}, { authorization: `Bearer ${bearer}` }),
318
+ deps(h),
319
+ );
320
+ expect(res.status).toBe(400);
321
+ const body = (await res.json()) as { error: string };
322
+ expect(body.error).toBe("invalid_request");
323
+ });
324
+
325
+ test("400 on invalid URL", async () => {
326
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
327
+ const res = await handleApiSettingsHubOrigin(
328
+ putReq({ hub_origin: "not-a-url" }, { authorization: `Bearer ${bearer}` }),
329
+ deps(h),
330
+ );
331
+ expect(res.status).toBe(400);
332
+ const body = (await res.json()) as { error: string };
333
+ expect(body.error).toBe("invalid_hub_origin");
334
+ });
335
+
336
+ test("400 on trailing slash", async () => {
337
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
338
+ const res = await handleApiSettingsHubOrigin(
339
+ putReq({ hub_origin: "https://hub.example.com/" }, { authorization: `Bearer ${bearer}` }),
340
+ deps(h),
341
+ );
342
+ expect(res.status).toBe(400);
343
+ });
344
+
345
+ test("400 on ftp: scheme", async () => {
346
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
347
+ const res = await handleApiSettingsHubOrigin(
348
+ putReq({ hub_origin: "ftp://hub.example.com" }, { authorization: `Bearer ${bearer}` }),
349
+ deps(h),
350
+ );
351
+ expect(res.status).toBe(400);
352
+ });
353
+
354
+ test("200 + writes the new value to hub_settings (https)", async () => {
355
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
356
+ const res = await handleApiSettingsHubOrigin(
357
+ putReq({ hub_origin: "https://hub.example.com" }, { authorization: `Bearer ${bearer}` }),
358
+ deps(h),
359
+ );
360
+ expect(res.status).toBe(200);
361
+ const body = (await res.json()) as { hub_origin: string | null };
362
+ expect(body.hub_origin).toBe("https://hub.example.com");
363
+ expect(getHubOrigin(h.db)).toBe("https://hub.example.com");
364
+ });
365
+
366
+ test("200 + accepts http loopback URL (dev shape)", async () => {
367
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
368
+ const res = await handleApiSettingsHubOrigin(
369
+ putReq({ hub_origin: "http://127.0.0.1:1939" }, { authorization: `Bearer ${bearer}` }),
370
+ deps(h),
371
+ );
372
+ expect(res.status).toBe(200);
373
+ expect(getHubOrigin(h.db)).toBe("http://127.0.0.1:1939");
374
+ });
375
+
376
+ test("200 + clears the value on null", async () => {
377
+ setHubOrigin(h.db, "https://hub.example.com");
378
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
379
+ const res = await handleApiSettingsHubOrigin(
380
+ putReq({ hub_origin: null }, { authorization: `Bearer ${bearer}` }),
381
+ deps(h),
382
+ );
383
+ expect(res.status).toBe(200);
384
+ const body = (await res.json()) as { hub_origin: string | null };
385
+ expect(body.hub_origin).toBeNull();
386
+ expect(getHubOrigin(h.db)).toBeNull();
387
+ });
388
+
389
+ test("200 + clears the value on empty string", async () => {
390
+ setHubOrigin(h.db, "https://hub.example.com");
391
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
392
+ const res = await handleApiSettingsHubOrigin(
393
+ putReq({ hub_origin: "" }, { authorization: `Bearer ${bearer}` }),
394
+ deps(h),
395
+ );
396
+ expect(res.status).toBe(200);
397
+ expect(getHubOrigin(h.db)).toBeNull();
398
+ });
399
+ });
400
+
401
+ describe("change takes effect on the next request (no restart needed)", () => {
402
+ let h: Harness;
403
+ beforeEach(async () => {
404
+ h = await makeHarness();
405
+ });
406
+ afterEach(() => h.cleanup());
407
+
408
+ test("GET → PUT → GET reflects the just-written value", async () => {
409
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
410
+
411
+ // Initial GET — nothing configured. The GET handler reads
412
+ // resolved_issuer from `deps`, so we re-walk the precedence chain
413
+ // on each request the way the dispatcher does in production.
414
+ const g1 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
415
+ db: h.db,
416
+ issuer: ISSUER,
417
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
418
+ resolvedSource: resolveIssuerSource(h.db, undefined),
419
+ });
420
+ const b1 = (await g1.json()) as { source: string; resolved_issuer: string };
421
+ expect(b1.source).toBe("request");
422
+
423
+ // Write through.
424
+ const p = await handleApiSettingsHubOrigin(
425
+ putReq({ hub_origin: "https://hub.example.com" }, { authorization: `Bearer ${bearer}` }),
426
+ {
427
+ db: h.db,
428
+ issuer: ISSUER,
429
+ resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined),
430
+ resolvedSource: resolveIssuerSource(h.db, undefined),
431
+ },
432
+ );
433
+ expect(p.status).toBe(200);
434
+
435
+ // Second GET — same dispatcher contract — sees the new value
436
+ // immediately. This is the core "no restart needed" claim.
437
+ const g2 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
438
+ db: h.db,
439
+ issuer: ISSUER,
440
+ resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
441
+ resolvedSource: resolveIssuerSource(h.db, undefined),
442
+ });
443
+ const b2 = (await g2.json()) as {
444
+ hub_origin: string | null;
445
+ source: string;
446
+ resolved_issuer: string;
447
+ };
448
+ expect(b2.hub_origin).toBe("https://hub.example.com");
449
+ expect(b2.source).toBe("settings");
450
+ expect(b2.resolved_issuer).toBe("https://hub.example.com");
451
+ });
452
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Bootstrap token (hub#297 first-boot-path hardening).
3
+ *
4
+ * The module is a tiny in-memory state machine: generate / consume /
5
+ * verify. These tests pin:
6
+ *
7
+ * - format (prefix + length sanity)
8
+ * - constant-time verify across length-equal vs. length-mismatched
9
+ * - lifecycle: generate → verify true → consume → verify false
10
+ * - regeneration replaces the prior value
11
+ * - "no token active" path returns false from verify
12
+ *
13
+ * Lifecycle tests reset the module between cases via
14
+ * `_resetBootstrapTokenForTests` so cross-test bleed is impossible.
15
+ */
16
+
17
+ import { afterEach, describe, expect, test } from "bun:test";
18
+ import {
19
+ BOOTSTRAP_TOKEN_PREFIX,
20
+ _resetBootstrapTokenForTests,
21
+ _setBootstrapTokenForTests,
22
+ consumeBootstrapToken,
23
+ generateBootstrapToken,
24
+ getBootstrapToken,
25
+ verifyBootstrapToken,
26
+ } from "../bootstrap-token.ts";
27
+
28
+ afterEach(() => {
29
+ _resetBootstrapTokenForTests();
30
+ });
31
+
32
+ describe("generateBootstrapToken", () => {
33
+ test("returns a string with the canonical `parachute-bootstrap-` prefix", () => {
34
+ const token = generateBootstrapToken();
35
+ expect(token.startsWith(BOOTSTRAP_TOKEN_PREFIX)).toBe(true);
36
+ });
37
+
38
+ test("produces a long-enough tail (≥40 base64url chars after prefix)", () => {
39
+ const token = generateBootstrapToken();
40
+ const tail = token.slice(BOOTSTRAP_TOKEN_PREFIX.length);
41
+ // 32 bytes of randomness → 43 base64url chars (no padding).
42
+ expect(tail.length).toBeGreaterThanOrEqual(40);
43
+ // base64url charset only — no `+/=` from un-translated base64.
44
+ expect(tail).toMatch(/^[A-Za-z0-9_-]+$/);
45
+ });
46
+
47
+ test("two consecutive generations produce different tokens", () => {
48
+ const a = generateBootstrapToken();
49
+ const b = generateBootstrapToken();
50
+ expect(a).not.toEqual(b);
51
+ });
52
+
53
+ test("a second call replaces (not appends to) the in-memory value", () => {
54
+ const a = generateBootstrapToken();
55
+ expect(getBootstrapToken()).toBe(a);
56
+ const b = generateBootstrapToken();
57
+ expect(getBootstrapToken()).toBe(b);
58
+ // The first token no longer verifies — it's been overwritten.
59
+ expect(verifyBootstrapToken(a)).toBe(false);
60
+ expect(verifyBootstrapToken(b)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("verifyBootstrapToken", () => {
65
+ test("returns false when no token has been generated", () => {
66
+ // Module reset by afterEach of prior test.
67
+ expect(getBootstrapToken()).toBeUndefined();
68
+ expect(verifyBootstrapToken("parachute-bootstrap-anything-at-all-here")).toBe(false);
69
+ });
70
+
71
+ test("returns true on exact match", () => {
72
+ const token = generateBootstrapToken();
73
+ expect(verifyBootstrapToken(token)).toBe(true);
74
+ });
75
+
76
+ test("returns false on wrong token of same length", () => {
77
+ const token = generateBootstrapToken();
78
+ // Flip the last char to produce a same-length-different-content string.
79
+ const last = token.slice(-1);
80
+ const flipped = last === "A" ? "B" : "A";
81
+ const wrong = `${token.slice(0, -1)}${flipped}`;
82
+ expect(wrong.length).toBe(token.length);
83
+ expect(verifyBootstrapToken(wrong)).toBe(false);
84
+ });
85
+
86
+ test("returns false on length-mismatched input (short)", () => {
87
+ generateBootstrapToken();
88
+ expect(verifyBootstrapToken("parachute-bootstrap-xyz")).toBe(false);
89
+ });
90
+
91
+ test("returns false on length-mismatched input (long)", () => {
92
+ const token = generateBootstrapToken();
93
+ expect(verifyBootstrapToken(`${token}-trailing-garbage`)).toBe(false);
94
+ });
95
+
96
+ test("returns false on empty / null / undefined input", () => {
97
+ generateBootstrapToken();
98
+ expect(verifyBootstrapToken("")).toBe(false);
99
+ expect(verifyBootstrapToken(null)).toBe(false);
100
+ expect(verifyBootstrapToken(undefined)).toBe(false);
101
+ });
102
+
103
+ test("returns false on a token that lacks the `parachute-bootstrap-` prefix", () => {
104
+ // Defense in depth: a constant-time compare against the active token
105
+ // refuses anything that doesn't byte-equal. Even a base64url string
106
+ // of identical length should reject when the prefix is gone.
107
+ _setBootstrapTokenForTests("parachute-bootstrap-ABCDEF0123456789ABCDEF0123456789ABC");
108
+ // Same length, swap the prefix for ATTACKER (matching length 9 chars
109
+ // of `parachute`).
110
+ expect(verifyBootstrapToken("ATTACKERxxxbootstrap-ABCDEF0123456789ABCDEF0123456789ABC")).toBe(
111
+ false,
112
+ );
113
+ });
114
+ });
115
+
116
+ describe("consumeBootstrapToken", () => {
117
+ test("clears the in-memory token so subsequent verifies fail", () => {
118
+ const token = generateBootstrapToken();
119
+ expect(verifyBootstrapToken(token)).toBe(true);
120
+ consumeBootstrapToken();
121
+ expect(verifyBootstrapToken(token)).toBe(false);
122
+ expect(getBootstrapToken()).toBeUndefined();
123
+ });
124
+
125
+ test("idempotent: re-consuming after already cleared is a no-op", () => {
126
+ generateBootstrapToken();
127
+ consumeBootstrapToken();
128
+ expect(() => consumeBootstrapToken()).not.toThrow();
129
+ expect(getBootstrapToken()).toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe("getBootstrapToken", () => {
134
+ test("returns undefined before generation", () => {
135
+ expect(getBootstrapToken()).toBeUndefined();
136
+ });
137
+
138
+ test("returns the active token after generation", () => {
139
+ const token = generateBootstrapToken();
140
+ expect(getBootstrapToken()).toBe(token);
141
+ });
142
+
143
+ test("returns undefined after consume", () => {
144
+ generateBootstrapToken();
145
+ consumeBootstrapToken();
146
+ expect(getBootstrapToken()).toBeUndefined();
147
+ });
148
+ });