@openparachute/hub 0.5.10 → 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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tests for the issuer precedence chain introduced in hub#298:
3
+ *
4
+ * hub_settings.hub_origin → configuredIssuer (env/flag) → request origin
5
+ *
6
+ * Both `resolveIssuer` (the canonical resolver) and `resolveIssuerSource`
7
+ * (the SPA-facing attribution helper) are exercised together so a future
8
+ * precedence drift can't surface in one without the other.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
15
+ import { resolveIssuer, resolveIssuerSource } from "../hub-server.ts";
16
+ import { setHubOrigin } from "../hub-settings.ts";
17
+
18
+ let dir: string;
19
+ let db: ReturnType<typeof openHubDb>;
20
+
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "hub-issuer-resolve-"));
23
+ db = openHubDb(hubDbPath(dir));
24
+ });
25
+ afterEach(() => {
26
+ db.close();
27
+ rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ function req(url: string): Request {
31
+ return new Request(url, { method: "GET" });
32
+ }
33
+
34
+ describe("resolveIssuer — precedence chain", () => {
35
+ test("falls back to request origin when no settings + no env", () => {
36
+ const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
37
+ expect(got).toBe("http://127.0.0.1:1939");
38
+ });
39
+
40
+ test("env wins over request origin (deploy-time setting)", () => {
41
+ const got = resolveIssuer(
42
+ req("http://127.0.0.1:1939/oauth/token"),
43
+ db,
44
+ "https://hub.from-env.example",
45
+ );
46
+ expect(got).toBe("https://hub.from-env.example");
47
+ });
48
+
49
+ test("hub_settings wins over env (operator override)", () => {
50
+ setHubOrigin(db, "https://hub.from-settings.example");
51
+ const got = resolveIssuer(
52
+ req("http://127.0.0.1:1939/oauth/token"),
53
+ db,
54
+ "https://hub.from-env.example",
55
+ );
56
+ expect(got).toBe("https://hub.from-settings.example");
57
+ });
58
+
59
+ test("hub_settings wins over request origin (no env)", () => {
60
+ setHubOrigin(db, "https://hub.from-settings.example");
61
+ const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
62
+ expect(got).toBe("https://hub.from-settings.example");
63
+ });
64
+
65
+ test("clearing hub_settings reverts to env precedence", () => {
66
+ setHubOrigin(db, "https://hub.from-settings.example");
67
+ setHubOrigin(db, null);
68
+ const got = resolveIssuer(
69
+ req("http://127.0.0.1:1939/oauth/token"),
70
+ db,
71
+ "https://hub.from-env.example",
72
+ );
73
+ expect(got).toBe("https://hub.from-env.example");
74
+ });
75
+
76
+ test("clearing hub_settings + no env reverts to request origin", () => {
77
+ setHubOrigin(db, "https://hub.from-settings.example");
78
+ setHubOrigin(db, null);
79
+ const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
80
+ expect(got).toBe("http://127.0.0.1:1939");
81
+ });
82
+
83
+ test("undefined db (pre-config gate) falls through to env then request", () => {
84
+ // The wellknown / discovery surfaces may hit oauthDeps before a DB
85
+ // is wired; resolveIssuer must not throw — just skip the settings
86
+ // layer.
87
+ const got = resolveIssuer(req("http://127.0.0.1:1939/"), undefined, undefined);
88
+ expect(got).toBe("http://127.0.0.1:1939");
89
+
90
+ const gotEnv = resolveIssuer(
91
+ req("http://127.0.0.1:1939/"),
92
+ undefined,
93
+ "https://hub.from-env.example",
94
+ );
95
+ expect(gotEnv).toBe("https://hub.from-env.example");
96
+ });
97
+
98
+ test("change takes effect on the very next request (no caching)", () => {
99
+ // Critical operator-facing behavior: the SPA save button must
100
+ // affect token mints on the subsequent OAuth request without a
101
+ // hub restart. Exercised by pulling resolveIssuer in sequence
102
+ // around a mid-flight setHubOrigin call.
103
+ const baseUrl = "http://127.0.0.1:1939/oauth/token";
104
+
105
+ // Pass 1 — no settings, no env → request origin.
106
+ expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
107
+
108
+ // Mid-flight write.
109
+ setHubOrigin(db, "https://hub.example.com");
110
+
111
+ // Pass 2 — settings wins immediately.
112
+ expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("https://hub.example.com");
113
+
114
+ // Mid-flight clear.
115
+ setHubOrigin(db, null);
116
+
117
+ // Pass 3 — back to request origin.
118
+ expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
119
+ });
120
+ });
121
+
122
+ describe("resolveIssuerSource — attribution for SPA", () => {
123
+ test('"request" when nothing is configured', () => {
124
+ expect(resolveIssuerSource(db, undefined)).toBe("request");
125
+ });
126
+
127
+ test('"env" when configuredIssuer is set + no settings row', () => {
128
+ expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("env");
129
+ });
130
+
131
+ test('"settings" when hub_settings row is set, even if env is also set', () => {
132
+ setHubOrigin(db, "https://hub.from-settings.example");
133
+ expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("settings");
134
+ });
135
+
136
+ test("attribution matches resolved value across the chain", () => {
137
+ // Pair them up so a future change to one without the other gets
138
+ // caught — the SPA helper text says "from settings" iff the
139
+ // settings layer is what got returned.
140
+ setHubOrigin(db, "https://hub.example.com");
141
+ const r1 = req("http://127.0.0.1:1939/oauth/token");
142
+ expect(resolveIssuer(r1, db, "https://hub.from-env.example")).toBe("https://hub.example.com");
143
+ expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("settings");
144
+
145
+ setHubOrigin(db, null);
146
+ expect(resolveIssuer(r1, db, "https://hub.from-env.example")).toBe(
147
+ "https://hub.from-env.example",
148
+ );
149
+ expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("env");
150
+
151
+ expect(resolveIssuer(r1, db, undefined)).toBe("http://127.0.0.1:1939");
152
+ expect(resolveIssuerSource(db, undefined)).toBe("request");
153
+ });
154
+ });
@@ -18,12 +18,14 @@ import {
18
18
  SETUP_EXPOSE_MODES,
19
19
  consumeFirstClientAutoApproveWindow,
20
20
  deleteSetting,
21
+ getHubOrigin,
21
22
  getModuleInstallChannel,
22
23
  getSetting,
23
24
  isFirstClientAutoApproveWindowOpen,
24
25
  isModuleInstallChannel,
25
26
  isSetupExposeMode,
26
27
  openFirstClientAutoApproveWindow,
28
+ setHubOrigin,
27
29
  setModuleInstallChannel,
28
30
  setSetting,
29
31
  } from "../hub-settings.ts";
@@ -375,3 +377,95 @@ describe("hub-settings — module install channel bootstrap", () => {
375
377
  }
376
378
  });
377
379
  });
380
+
381
+ describe("hub-settings — hub_origin (hub#298)", () => {
382
+ let dir: string;
383
+ beforeEach(() => {
384
+ dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
385
+ });
386
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
387
+
388
+ test("getHubOrigin returns null when no row is present", () => {
389
+ const db = openHubDb(hubDbPath(dir));
390
+ try {
391
+ expect(getHubOrigin(db)).toBeNull();
392
+ } finally {
393
+ db.close();
394
+ }
395
+ });
396
+
397
+ test("setHubOrigin then getHubOrigin round-trips the value", () => {
398
+ const db = openHubDb(hubDbPath(dir));
399
+ try {
400
+ setHubOrigin(db, "https://hub.example.com");
401
+ expect(getHubOrigin(db)).toBe("https://hub.example.com");
402
+ } finally {
403
+ db.close();
404
+ }
405
+ });
406
+
407
+ test("setHubOrigin overwrites an existing value", () => {
408
+ const db = openHubDb(hubDbPath(dir));
409
+ try {
410
+ setHubOrigin(db, "https://hub.example.com");
411
+ setHubOrigin(db, "https://hub.other.example");
412
+ expect(getHubOrigin(db)).toBe("https://hub.other.example");
413
+ } finally {
414
+ db.close();
415
+ }
416
+ });
417
+
418
+ test("setHubOrigin(null) clears the row → getHubOrigin returns null", () => {
419
+ const db = openHubDb(hubDbPath(dir));
420
+ try {
421
+ setHubOrigin(db, "https://hub.example.com");
422
+ setHubOrigin(db, null);
423
+ expect(getHubOrigin(db)).toBeNull();
424
+ // Idempotent — a second clear on an already-absent row is a no-op.
425
+ setHubOrigin(db, null);
426
+ expect(getHubOrigin(db)).toBeNull();
427
+ } finally {
428
+ db.close();
429
+ }
430
+ });
431
+
432
+ test('setHubOrigin("") is treated as null (no falsy-row footgun)', () => {
433
+ // An empty string would be a useless issuer + would cause source
434
+ // attribution to lie ("from settings" while no real value).
435
+ // Normalize at the write layer.
436
+ const db = openHubDb(hubDbPath(dir));
437
+ try {
438
+ setHubOrigin(db, "https://hub.example.com");
439
+ setHubOrigin(db, "");
440
+ expect(getHubOrigin(db)).toBeNull();
441
+ } finally {
442
+ db.close();
443
+ }
444
+ });
445
+
446
+ test("does not auto-seed from env (unlike module_install_channel)", () => {
447
+ // The env var (PARACHUTE_HUB_ORIGIN) is a separate precedence layer
448
+ // in resolveIssuer (env wins when no settings row). Auto-seeding
449
+ // would collapse env → settings and lose the source attribution
450
+ // the SPA exposes ("from env" vs "from settings"). Verify no row
451
+ // appears just from reading.
452
+ const prior = process.env.PARACHUTE_HUB_ORIGIN;
453
+ process.env.PARACHUTE_HUB_ORIGIN = "https://hub.from-env.example";
454
+ try {
455
+ const db = openHubDb(hubDbPath(dir));
456
+ try {
457
+ expect(getHubOrigin(db)).toBeNull();
458
+ } finally {
459
+ db.close();
460
+ }
461
+ } finally {
462
+ if (prior === undefined) {
463
+ // Bun's process.env supports the `[key]: undefined` shape
464
+ // (biome's noDelete rule preferred this over `delete`).
465
+ process.env.PARACHUTE_HUB_ORIGIN = undefined;
466
+ } else {
467
+ process.env.PARACHUTE_HUB_ORIGIN = prior;
468
+ }
469
+ }
470
+ });
471
+ });
@@ -308,6 +308,26 @@ describe("substituteVaultDisplay", () => {
308
308
  // consenting to literally.
309
309
  expect(substituteVaultDisplay("vault:admin", "work")).toBe("vault:admin");
310
310
  });
311
+
312
+ test("'*' → renders the wildcard display form (vault:*:<verb>)", () => {
313
+ // Approve-time rendering: no vault has been picked yet (the consent
314
+ // picker hasn't run), but rendering the raw `vault:read` form implies
315
+ // unrestricted full-vault access. The wildcard signals "scope will be
316
+ // narrowed to a specific vault at consent" — mirrors the SPA's
317
+ // `/admin/approve-client/<id>` view.
318
+ expect(substituteVaultDisplay("vault:read", "*")).toBe("vault:*:read");
319
+ expect(substituteVaultDisplay("vault:write", "*")).toBe("vault:*:write");
320
+ });
321
+
322
+ test("'*' → non-vault scopes still pass through unchanged", () => {
323
+ expect(substituteVaultDisplay("scribe:transcribe", "*")).toBe("scribe:transcribe");
324
+ expect(substituteVaultDisplay("channel:send", "*")).toBe("channel:send");
325
+ });
326
+
327
+ test("'*' → already-named vault scopes pass through (caller specified the vault)", () => {
328
+ expect(substituteVaultDisplay("vault:work:read", "*")).toBe("vault:work:read");
329
+ expect(substituteVaultDisplay("vault:other:write", "*")).toBe("vault:other:write");
330
+ });
311
331
  });
312
332
 
313
333
  describe("renderConsent displayVault substitution", () => {
@@ -442,6 +462,103 @@ describe("renderApprovePending unauthenticated CTAs", () => {
442
462
  });
443
463
  });
444
464
 
465
+ describe("renderApprovePending scope display (wildcard substitution)", () => {
466
+ const COMMON = {
467
+ clientName: "MyApp",
468
+ clientId: "client-xyz",
469
+ redirectUris: ["https://app.example/cb"],
470
+ hubOrigin: "https://hub.example.com",
471
+ };
472
+
473
+ test("unnamed vault scopes render with the wildcard form (vault:*:<verb>)", () => {
474
+ // The bug Aaron hit on rc.1: the server-rendered approve-pending page
475
+ // showed raw `vault:read` / `vault:write` rather than the resolved
476
+ // `vault:*:<verb>` form the SPA already used. Both the unauth viewer
477
+ // and the authenticated admin branch get the substituted display so
478
+ // they match the SPA's `/admin/approve-client/<id>` view.
479
+ const html = renderApprovePending({
480
+ ...COMMON,
481
+ requestedScopes: ["vault:read", "vault:write"],
482
+ });
483
+ expect(html).toMatch(/<code class="scope-name">vault:\*:read<\/code>/);
484
+ expect(html).toMatch(/<code class="scope-name">vault:\*:write<\/code>/);
485
+ // Raw unnamed form must NOT appear in the rendered scope-row code
486
+ // blocks any more — that was the bug.
487
+ expect(html).not.toMatch(/<code class="scope-name">vault:read<\/code>/);
488
+ expect(html).not.toMatch(/<code class="scope-name">vault:write<\/code>/);
489
+ });
490
+
491
+ test("wildcard explanation surfaces below the scope list when any scope renders with `*`", () => {
492
+ const html = renderApprovePending({
493
+ ...COMMON,
494
+ requestedScopes: ["vault:read"],
495
+ });
496
+ // The `<p class="scope-wildcard-note">…` element is what's conditional;
497
+ // `.scope-wildcard-note` as a CSS class name is always present in the
498
+ // stylesheet, so match the rendered element specifically.
499
+ expect(html).toMatch(/<p class="scope-wildcard-note">/);
500
+ expect(html).toContain("a specific vault is selected during sign-in");
501
+ expect(html).toContain("unbound shape");
502
+ });
503
+
504
+ test("wildcard explanation is omitted when no scope renders with `*`", () => {
505
+ // All-non-vault: explanation absent.
506
+ const nonVault = renderApprovePending({
507
+ ...COMMON,
508
+ requestedScopes: ["scribe:transcribe", "channel:send"],
509
+ });
510
+ expect(nonVault).not.toMatch(/<p class="scope-wildcard-note">/);
511
+ expect(nonVault).not.toContain("unbound shape");
512
+
513
+ // Already-named vault: explanation absent (vault already specified).
514
+ const named = renderApprovePending({
515
+ ...COMMON,
516
+ requestedScopes: ["vault:work:read"],
517
+ });
518
+ expect(named).not.toMatch(/<p class="scope-wildcard-note">/);
519
+ expect(named).not.toContain("unbound shape");
520
+
521
+ // Empty scopes: explanation absent (no scope rows at all).
522
+ const empty = renderApprovePending({ ...COMMON, requestedScopes: [] });
523
+ expect(empty).not.toMatch(/<p class="scope-wildcard-note">/);
524
+ });
525
+
526
+ test("already-named vault scopes still render as-is (no double substitution)", () => {
527
+ const html = renderApprovePending({
528
+ ...COMMON,
529
+ requestedScopes: ["vault:work:read"],
530
+ });
531
+ expect(html).toMatch(/<code class="scope-name">vault:work:read<\/code>/);
532
+ // Pre-existing named scopes don't get mangled by the wildcard substitution.
533
+ expect(html).not.toMatch(/vault:\*:work/);
534
+ });
535
+
536
+ test("mixed scopes render the wildcard explanation when ANY scope is unnamed-vault", () => {
537
+ const html = renderApprovePending({
538
+ ...COMMON,
539
+ requestedScopes: ["scribe:transcribe", "vault:read"],
540
+ });
541
+ expect(html).toMatch(/<code class="scope-name">scribe:transcribe<\/code>/);
542
+ expect(html).toMatch(/<code class="scope-name">vault:\*:read<\/code>/);
543
+ expect(html).toMatch(/<p class="scope-wildcard-note">/);
544
+ });
545
+
546
+ test("authenticated admin branch also gets the wildcard substitution + explanation", () => {
547
+ // Both branches of the page render through the same scope-display path
548
+ // — the inline-form admin viewer sees the same resolved scopes as the
549
+ // unauth viewer with the sign-in CTA.
550
+ const html = renderApprovePending({
551
+ ...COMMON,
552
+ requestedScopes: ["vault:read", "vault:write"],
553
+ approveForm: { csrfToken: CSRF, returnTo: "/oauth/authorize?client_id=client-xyz" },
554
+ });
555
+ expect(html).toContain('action="/oauth/authorize/approve"');
556
+ expect(html).toMatch(/<code class="scope-name">vault:\*:read<\/code>/);
557
+ expect(html).toMatch(/<code class="scope-name">vault:\*:write<\/code>/);
558
+ expect(html).toMatch(/<p class="scope-wildcard-note">/);
559
+ });
560
+ });
561
+
445
562
  describe("CSS / styling guarantees", () => {
446
563
  test("does not load fonts from a third-party CDN (privacy)", () => {
447
564
  const html = renderLogin({ params: PARAMS, csrfToken: CSRF });
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { seedInitialAdminIfNeeded } from "../commands/serve.ts";
5
+ import { _resetBootstrapTokenForTests, getBootstrapToken } from "../bootstrap-token.ts";
6
+ import {
7
+ formatBootstrapTokenBanner,
8
+ formatListeningBanner,
9
+ seedInitialAdminIfNeeded,
10
+ } from "../commands/serve.ts";
6
11
  import { openHubDb } from "../hub-db.ts";
7
12
  import { getUserByUsername, userCount } from "../users.ts";
8
13
 
@@ -105,3 +110,129 @@ describe("seedInitialAdminIfNeeded", () => {
105
110
  expect(userCount(db)).toBe(0);
106
111
  });
107
112
  });
113
+
114
+ describe("formatListeningBanner", () => {
115
+ const base = {
116
+ port: 1939,
117
+ configDir: "/home/op/.parachute",
118
+ dbPath: "/home/op/.parachute/hub.db",
119
+ issuer: undefined,
120
+ adminBootstrap: "exists",
121
+ };
122
+
123
+ test("hostname 0.0.0.0 → display localhost + bound note", () => {
124
+ // Chrome refuses to navigate to `0.0.0.0`, and mixing it with
125
+ // `localhost` trips cross-origin errors. Operators paste the URL
126
+ // straight from the banner, so the printed URL must be navigable.
127
+ const line = formatListeningBanner({ ...base, hostname: "0.0.0.0" });
128
+ expect(line).toContain("listening on http://localhost:1939");
129
+ expect(line).toContain("(bound on all interfaces: 0.0.0.0:1939)");
130
+ // The bind disclosure should sit between the URL and the contextual
131
+ // PARACHUTE_HOME / db / issuer block, so an operator scanning the
132
+ // banner sees the URL first and the bind note second.
133
+ const urlIdx = line.indexOf("http://localhost:1939");
134
+ const boundIdx = line.indexOf("(bound on all interfaces");
135
+ const homeIdx = line.indexOf("PARACHUTE_HOME=");
136
+ expect(urlIdx).toBeLessThan(boundIdx);
137
+ expect(boundIdx).toBeLessThan(homeIdx);
138
+ });
139
+
140
+ test("hostname 127.0.0.1 (operator-chosen loopback) → display verbatim, no bound note", () => {
141
+ const line = formatListeningBanner({ ...base, hostname: "127.0.0.1" });
142
+ expect(line).toContain("listening on http://127.0.0.1:1939");
143
+ expect(line).not.toContain("bound on all interfaces");
144
+ });
145
+
146
+ test("hostname 192.168.x.x (operator-chosen LAN IP) → display verbatim, no bound note", () => {
147
+ const line = formatListeningBanner({ ...base, hostname: "192.168.1.10" });
148
+ expect(line).toContain("listening on http://192.168.1.10:1939");
149
+ expect(line).not.toContain("bound on all interfaces");
150
+ });
151
+
152
+ test("contextual block carries PARACHUTE_HOME, db, issuer, admin state", () => {
153
+ const line = formatListeningBanner({
154
+ ...base,
155
+ hostname: "0.0.0.0",
156
+ issuer: "https://hub.example.com",
157
+ adminBootstrap: "seeded",
158
+ });
159
+ expect(line).toContain("PARACHUTE_HOME=/home/op/.parachute");
160
+ expect(line).toContain("db=/home/op/.parachute/hub.db");
161
+ expect(line).toContain("issuer=https://hub.example.com");
162
+ expect(line).toContain("admin=seeded");
163
+ });
164
+
165
+ test("issuer undefined renders as <request-origin> placeholder", () => {
166
+ const line = formatListeningBanner({ ...base, hostname: "0.0.0.0" });
167
+ expect(line).toContain("issuer=<request-origin>");
168
+ });
169
+ });
170
+
171
+ // --- bootstrap-token banner (first-boot-path hardening, Issue 1) ---------
172
+
173
+ describe("formatBootstrapTokenBanner", () => {
174
+ test("includes the token verbatim, the wizard URL, and an expiry note", () => {
175
+ const banner = formatBootstrapTokenBanner("parachute-bootstrap-sample-token-abc-123");
176
+ expect(banner).toContain("parachute-bootstrap-sample-token-abc-123");
177
+ expect(banner).toContain("/admin/setup");
178
+ expect(banner).toContain("admin is created OR when hub restarts");
179
+ });
180
+
181
+ test("each line carries the `[wizard]` prefix so a log scanner can isolate the block", () => {
182
+ const banner = formatBootstrapTokenBanner("parachute-bootstrap-x");
183
+ for (const line of banner.split("\n")) {
184
+ expect(line.startsWith("[wizard]")).toBe(true);
185
+ }
186
+ });
187
+ });
188
+
189
+ // --- bootstrap-token generation under needs-setup (Issue 1 wiring) -------
190
+ //
191
+ // `seedInitialAdminIfNeeded` returns `needs-setup` when no admin row and
192
+ // no env vars; the wizard-mode boot path then mints + logs the token.
193
+ // These tests pin the wiring: when `needs-setup` fires, the token slot
194
+ // gets populated; when the env-seed path fires, the token slot stays
195
+ // undefined.
196
+
197
+ describe("bootstrap-token wiring under needs-setup", () => {
198
+ let dir: string;
199
+ let dbPath: string;
200
+
201
+ beforeEach(() => {
202
+ dir = mkdtempSync(join(tmpdir(), "parachute-serve-bootstrap-"));
203
+ dbPath = join(dir, "hub.db");
204
+ _resetBootstrapTokenForTests();
205
+ });
206
+
207
+ afterEach(() => {
208
+ rmSync(dir, { recursive: true, force: true });
209
+ _resetBootstrapTokenForTests();
210
+ });
211
+
212
+ test("needs-setup branch: seedInitialAdminIfNeeded itself does NOT mint a token", async () => {
213
+ // The token mint is in serve() — the surrounding fetch loop — not
214
+ // in `seedInitialAdminIfNeeded`. This pins the helper's contract:
215
+ // pure state inspection, no side effects beyond the admin row.
216
+ // (The mint happens in the caller; covered by integration via the
217
+ // hub-side gate tests that exercise the wizard against a generated
218
+ // token.)
219
+ const db = openHubDb(dbPath);
220
+ const result = await seedInitialAdminIfNeeded(db, {}, () => {});
221
+ expect(result).toBe("needs-setup");
222
+ expect(getBootstrapToken()).toBeUndefined();
223
+ });
224
+
225
+ test("env-seed branch: no token generated by the seed helper", async () => {
226
+ const db = openHubDb(dbPath);
227
+ const result = await seedInitialAdminIfNeeded(
228
+ db,
229
+ {
230
+ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
231
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "correct horse battery staple",
232
+ },
233
+ () => {},
234
+ );
235
+ expect(result).toBe("seeded");
236
+ expect(getBootstrapToken()).toBeUndefined();
237
+ });
238
+ });
@@ -219,4 +219,97 @@ describe("setup gate (admin exists)", () => {
219
219
  db.close();
220
220
  }
221
221
  });
222
+
223
+ // Issue 2 (first-boot-path hardening): the auto-redirect on `/` and
224
+ // `/hub.html` fires whenever the wizard still has work to do — not just
225
+ // when the admin row is missing. Pre-fix, an env-seeded admin with no
226
+ // vault landed on the static discovery portal and had to hand-find
227
+ // `/admin/modules` + `/admin/vaults`. Post-fix, `/` funnels them
228
+ // straight to the wizard's vault step.
229
+ test("/ 302s to /admin/setup when env-seeded admin has no vault (Issue 2)", async () => {
230
+ const db = openHubDb(hubDbPath(h.dir));
231
+ try {
232
+ // Simulate env-seed: admin row exists, services.json is empty.
233
+ await createUser(db, "env-seeded-admin", "pw");
234
+ const res = await hubFetch(h.dir, {
235
+ getDb: () => db,
236
+ manifestPath: join(h.dir, "services.json"),
237
+ })(req("/"));
238
+ expect(res.status).toBe(302);
239
+ expect(res.headers.get("location")).toBe("/admin/setup");
240
+ } finally {
241
+ db.close();
242
+ }
243
+ });
244
+
245
+ test("/hub.html 302s to /admin/setup when env-seeded admin has no vault (Issue 2)", async () => {
246
+ const db = openHubDb(hubDbPath(h.dir));
247
+ try {
248
+ await createUser(db, "env-seeded-admin", "pw");
249
+ const res = await hubFetch(h.dir, {
250
+ getDb: () => db,
251
+ manifestPath: join(h.dir, "services.json"),
252
+ })(req("/hub.html"));
253
+ expect(res.status).toBe(302);
254
+ expect(res.headers.get("location")).toBe("/admin/setup");
255
+ } finally {
256
+ db.close();
257
+ }
258
+ });
259
+
260
+ test("/ renders the discovery page when admin + vault both exist", async () => {
261
+ const db = openHubDb(hubDbPath(h.dir));
262
+ try {
263
+ await createUser(db, "owner", "pw");
264
+ writeManifest(
265
+ {
266
+ services: [
267
+ {
268
+ name: "parachute-vault",
269
+ version: "0.1.0",
270
+ port: 1940,
271
+ paths: ["/vault/default"],
272
+ health: "/health",
273
+ },
274
+ ],
275
+ },
276
+ join(h.dir, "services.json"),
277
+ );
278
+ const res = await hubFetch(h.dir, {
279
+ getDb: () => db,
280
+ manifestPath: join(h.dir, "services.json"),
281
+ })(req("/"));
282
+ // 200 (the dynamic discovery page) — NOT 302 to /admin/setup. The
283
+ // wizard's work is done.
284
+ expect(res.status).toBe(200);
285
+ expect(res.headers.get("content-type")).toContain("text/html");
286
+ } finally {
287
+ db.close();
288
+ }
289
+ });
290
+
291
+ test("wizard at /admin/setup with env-seeded admin + no vault renders vault step (Issue 2)", async () => {
292
+ // Mirror the wizard's resume-at-vault-step shape for the env-seed
293
+ // path. Same as the existing test above, but explicitly named to
294
+ // document the Issue 2 expectation: the wizard handles env-seeded
295
+ // admins correctly.
296
+ const db = openHubDb(hubDbPath(h.dir));
297
+ try {
298
+ await createUser(db, "env-seeded-admin", "pw");
299
+ const res = await hubFetch(h.dir, {
300
+ getDb: () => db,
301
+ manifestPath: join(h.dir, "services.json"),
302
+ })(req("/admin/setup"));
303
+ expect(res.status).toBe(200);
304
+ const html = await res.text();
305
+ // Vault step is rendered (the form action gives it away).
306
+ expect(html).toContain('action="/admin/setup/vault"');
307
+ // Account step is NOT rendered — no username field, no bootstrap
308
+ // token field.
309
+ expect(html).not.toContain('name="bootstrap_token"');
310
+ expect(html).not.toContain('name="password_confirm"');
311
+ } finally {
312
+ db.close();
313
+ }
314
+ });
222
315
  });