@openparachute/hub 0.5.14-rc.9 → 0.6.0

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.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +30 -21
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-users.test.ts +7 -2
  9. package/src/__tests__/auth.test.ts +157 -30
  10. package/src/__tests__/cli.test.ts +44 -5
  11. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  12. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  13. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  14. package/src/__tests__/expose.test.ts +52 -2
  15. package/src/__tests__/hub-server.test.ts +97 -0
  16. package/src/__tests__/hub.test.ts +85 -6
  17. package/src/__tests__/init.test.ts +102 -1
  18. package/src/__tests__/lifecycle.test.ts +464 -2
  19. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  20. package/src/__tests__/oauth-ui.test.ts +12 -1
  21. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  22. package/src/__tests__/resource-binding.test.ts +97 -0
  23. package/src/__tests__/scope-explanations.test.ts +41 -12
  24. package/src/__tests__/services-manifest.test.ts +122 -4
  25. package/src/__tests__/setup-wizard.test.ts +335 -15
  26. package/src/__tests__/status.test.ts +36 -0
  27. package/src/__tests__/two-factor-flow.test.ts +602 -0
  28. package/src/__tests__/two-factor.test.ts +183 -0
  29. package/src/__tests__/upgrade.test.ts +78 -1
  30. package/src/__tests__/users.test.ts +68 -0
  31. package/src/__tests__/vault-auth-status.test.ts +47 -6
  32. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  33. package/src/account-home-ui.ts +488 -38
  34. package/src/account-vault-token.ts +282 -0
  35. package/src/admin-handlers.ts +159 -4
  36. package/src/admin-login-ui.ts +49 -5
  37. package/src/admin-vaults.ts +48 -15
  38. package/src/api-account.ts +14 -0
  39. package/src/api-modules-ops.ts +49 -11
  40. package/src/api-users.ts +29 -3
  41. package/src/cli.ts +26 -21
  42. package/src/clients.ts +18 -6
  43. package/src/cloudflare/config.ts +10 -4
  44. package/src/cloudflare/detect.ts +39 -44
  45. package/src/commands/auth.ts +165 -24
  46. package/src/commands/expose-2fa-warning.ts +34 -32
  47. package/src/commands/expose-auth-preflight.ts +89 -78
  48. package/src/commands/expose-cloudflare.ts +370 -12
  49. package/src/commands/expose.ts +8 -0
  50. package/src/commands/init.ts +33 -2
  51. package/src/commands/lifecycle.ts +386 -17
  52. package/src/commands/status.ts +22 -0
  53. package/src/commands/upgrade.ts +55 -11
  54. package/src/commands/wizard.ts +8 -4
  55. package/src/env-file.ts +10 -0
  56. package/src/help.ts +3 -1
  57. package/src/hub-db.ts +39 -1
  58. package/src/hub-server.ts +52 -0
  59. package/src/hub.ts +82 -14
  60. package/src/oauth-handlers.ts +298 -21
  61. package/src/oauth-ui.ts +10 -0
  62. package/src/operator-token.ts +151 -0
  63. package/src/pending-login.ts +116 -0
  64. package/src/rate-limit.ts +51 -0
  65. package/src/resource-binding.ts +134 -0
  66. package/src/scope-explanations.ts +46 -18
  67. package/src/services-manifest.ts +112 -0
  68. package/src/setup-wizard.ts +77 -7
  69. package/src/tailscale/run.ts +28 -11
  70. package/src/totp.ts +201 -0
  71. package/src/two-factor-handlers.ts +287 -0
  72. package/src/two-factor-store.ts +181 -0
  73. package/src/two-factor-ui.ts +462 -0
  74. package/src/users.ts +58 -0
  75. package/src/vault/auth-status.ts +71 -19
  76. package/src/vault-hub-origin-env.ts +163 -0
  77. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  78. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  79. package/web/ui/dist/index.html +2 -2
  80. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  81. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  82. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  83. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -65,34 +65,159 @@ async function captureOutput(fn: () => Promise<number> | number): Promise<{
65
65
  }
66
66
 
67
67
  describe("parachute auth", () => {
68
- test("2fa enroll forwards to parachute-vault 2fa enroll", async () => {
69
- const { runner, calls } = makeRunner(0);
70
- const code = await auth(["2fa", "enroll"], runner);
71
- expect(code).toBe(0);
72
- expect(calls).toEqual([["parachute-vault", "2fa", "enroll"]]);
68
+ test("2fa is hub-local — never forwards to parachute-vault", async () => {
69
+ // 2fa used to forward to the deprecated `parachute-vault 2fa` stub. As of
70
+ // hub#473 it's real hub-login TOTP, fully hub-local (hub.db). No subprocess.
71
+ const tmp = makeTmp();
72
+ try {
73
+ const db = openHubDb(tmp.dbPath);
74
+ await createUser(db, "owner", "owner-password-123");
75
+ db.close();
76
+ const { runner, calls } = makeRunner(0);
77
+ const out = await captureOutput(() =>
78
+ auth(["2fa", "status"], { runner, dbPath: tmp.dbPath }),
79
+ );
80
+ expect(out.code).toBe(0);
81
+ expect(calls).toEqual([]); // did NOT spawn parachute-vault
82
+ expect(out.stdout).toContain("Two-factor authentication: OFF");
83
+ } finally {
84
+ tmp.cleanup();
85
+ }
73
86
  });
74
87
 
75
- test("2fa enroll --some-flag forwards every arg after the subcommand", async () => {
76
- const { runner, calls } = makeRunner(0);
77
- const code = await auth(["2fa", "enroll", "--some-flag", "value"], runner);
78
- expect(code).toBe(0);
79
- expect(calls).toEqual([["parachute-vault", "2fa", "enroll", "--some-flag", "value"]]);
88
+ test("2fa status reports OFF for a fresh user, then ON after a CLI enroll round-trip", async () => {
89
+ const tmp = makeTmp();
90
+ try {
91
+ const db = openHubDb(tmp.dbPath);
92
+ await createUser(db, "owner", "owner-password-123");
93
+ db.close();
94
+
95
+ // status → OFF
96
+ const off = await captureOutput(() =>
97
+ auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
98
+ );
99
+ expect(off.code).toBe(0);
100
+ expect(off.stdout).toContain("OFF");
101
+
102
+ // enroll requires a confirm code — drive the readLine seam with the
103
+ // live TOTP code generated from the secret the command prints.
104
+ const { generateTotpSecret } = await import("../totp.ts");
105
+ // We can't intercept the random secret the command mints, so instead
106
+ // exercise the store directly to assert the ON path is reachable, then
107
+ // assert the CLI status reflects it. (The handler-level enroll round
108
+ // trip is covered in two-factor.test.ts against the real secret.)
109
+ const db2 = openHubDb(tmp.dbPath);
110
+ const { persistEnrollment } = await import("../two-factor-store.ts");
111
+ const u = listUsers(db2)[0]!;
112
+ const { secret } = generateTotpSecret(u.username);
113
+ await persistEnrollment(db2, u.id, secret);
114
+ db2.close();
115
+
116
+ const on = await captureOutput(() =>
117
+ auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
118
+ );
119
+ expect(on.code).toBe(0);
120
+ expect(on.stdout).toContain("ON");
121
+ expect(on.stdout).toContain("backup_codes");
122
+
123
+ // disenroll clears it.
124
+ const dis = await captureOutput(() =>
125
+ auth(["2fa", "disenroll"], { dbPath: tmp.dbPath, isInteractive: () => false }),
126
+ );
127
+ expect(dis.code).toBe(0);
128
+ expect(dis.stdout).toContain("Turned off");
129
+
130
+ const off2 = await captureOutput(() =>
131
+ auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
132
+ );
133
+ expect(off2.stdout).toContain("OFF");
134
+ } finally {
135
+ tmp.cleanup();
136
+ }
80
137
  });
81
138
 
82
- test("exit code from parachute-vault is propagated", async () => {
83
- const { runner } = makeRunner(3);
84
- const code = await auth(["2fa", "status"], runner);
85
- expect(code).toBe(3);
139
+ test("2fa enroll confirms the printed secret against a live code, prints backup codes", async () => {
140
+ const tmp = makeTmp();
141
+ try {
142
+ const db = openHubDb(tmp.dbPath);
143
+ await createUser(db, "owner", "owner-password-123");
144
+ db.close();
145
+
146
+ // Single console.log interception that BOTH accumulates stdout and lets
147
+ // the readLine seam read the secret the command just printed. (Avoids
148
+ // nesting two console.log replacements.)
149
+ const OTPAuth = await import("otpauth");
150
+ const origLog = console.log;
151
+ let stdout = "";
152
+ let capturedSecret = "";
153
+ console.log = (...a: unknown[]) => {
154
+ const line = a.map(String).join(" ");
155
+ stdout += `${line}\n`;
156
+ const m = line.match(/secret key:\s+([A-Z2-7]+)/);
157
+ if (m) capturedSecret = m[1]!;
158
+ };
159
+ let code = "";
160
+ let exitCode = 0;
161
+ try {
162
+ exitCode = await auth(["2fa", "enroll"], {
163
+ dbPath: tmp.dbPath,
164
+ isInteractive: () => true,
165
+ readLine: async () => {
166
+ // The secret has been printed by the time the prompt fires.
167
+ const totp = new OTPAuth.TOTP({
168
+ issuer: "Parachute Hub",
169
+ label: "owner",
170
+ algorithm: "SHA1",
171
+ digits: 6,
172
+ period: 30,
173
+ secret: OTPAuth.Secret.fromBase32(capturedSecret),
174
+ });
175
+ code = totp.generate();
176
+ return code;
177
+ },
178
+ });
179
+ } finally {
180
+ console.log = origLog;
181
+ }
182
+ expect(exitCode).toBe(0);
183
+ expect(capturedSecret.length).toBeGreaterThan(0);
184
+ expect(stdout).toContain("now ON");
185
+ // 10 backup codes printed (hyphenated form).
186
+ const backupLines = stdout.split("\n").filter((l) => /^ {2}[a-z2-9]{5}-[a-z2-9]{5}$/.test(l));
187
+ expect(backupLines.length).toBe(10);
188
+ expect(code.length).toBe(6);
189
+ // The persisted state reflects the enrollment: the captured secret is
190
+ // now stored on the user row. (We don't re-verify `code` — the enroll
191
+ // confirm consumed it via the replay cache, by design.)
192
+ const db2 = openHubDb(tmp.dbPath);
193
+ const { getTotpState } = await import("../two-factor-store.ts");
194
+ const uid = listUsers(db2)[0]!.id;
195
+ const persisted = getTotpState(db2, uid);
196
+ expect(persisted.secret).toBe(capturedSecret);
197
+ expect(persisted.backupCodes.length).toBe(10);
198
+ db2.close();
199
+ } finally {
200
+ tmp.cleanup();
201
+ }
86
202
  });
87
203
 
88
- test("ENOENT on a vault-forwarded subcommand surfaces install hint and exit 127", async () => {
89
- const runner: Runner = {
90
- async run() {
91
- throw new Error("ENOENT: spawn parachute-vault");
92
- },
93
- };
94
- const code = await auth(["2fa", "status"], runner);
95
- expect(code).toBe(127);
204
+ test("2fa enroll refuses to re-enroll when already on", async () => {
205
+ const tmp = makeTmp();
206
+ try {
207
+ const db = openHubDb(tmp.dbPath);
208
+ const u = await createUser(db, "owner", "owner-password-123");
209
+ const { generateTotpSecret } = await import("../totp.ts");
210
+ const { persistEnrollment } = await import("../two-factor-store.ts");
211
+ await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
212
+ db.close();
213
+ const out = await captureOutput(() =>
214
+ auth(["2fa", "enroll"], { dbPath: tmp.dbPath, isInteractive: () => true }),
215
+ );
216
+ expect(out.code).toBe(1);
217
+ expect(out.stderr).toContain("already enabled");
218
+ } finally {
219
+ tmp.cleanup();
220
+ }
96
221
  });
97
222
 
98
223
  test("set-password no longer forwards to vault", async () => {
@@ -142,23 +267,25 @@ describe("authHelp", () => {
142
267
  test("lists every blessed subcommand", () => {
143
268
  expect(h).toContain("parachute auth set-password");
144
269
  expect(h).toContain("parachute auth list-users");
145
- expect(h).toContain("parachute auth 2fa status");
146
- expect(h).toContain("parachute auth 2fa enroll");
147
- expect(h).toContain("parachute auth 2fa disable");
148
- expect(h).toContain("parachute auth 2fa backup-codes");
270
+ expect(h).toContain("parachute auth 2fa");
149
271
  expect(h).toContain("parachute auth rotate-key");
150
272
  });
151
273
 
274
+ test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
275
+ expect(h).toContain("#473");
276
+ // Real enroll / disenroll subcommands are now advertised.
277
+ expect(h).toContain("2fa enroll");
278
+ expect(h).toContain("2fa disenroll");
279
+ expect(h).toContain("otpauth://");
280
+ expect(h).toContain("backup codes");
281
+ });
282
+
152
283
  test("set-password help mentions the new flags + hub-local home", () => {
153
284
  expect(h).toContain("--username");
154
285
  expect(h).toContain("--allow-multi");
155
286
  expect(h).toContain("hub.db");
156
287
  });
157
288
 
158
- test("mentions the vault-install hint", () => {
159
- expect(h).toContain("parachute install vault");
160
- });
161
-
162
289
  test("rotate-key explains the 24h JWKS retention", () => {
163
290
  expect(h).toContain("jwks.json");
164
291
  // "24" + "hours" may be split by line wrap; check both pieces.
@@ -123,6 +123,43 @@ describe("cli per-subcommand help", () => {
123
123
  expect(stderr).toMatch(/dash\.cloudflare\.com/);
124
124
  });
125
125
 
126
+ test("expose cloudflare is an alias for expose public --cloudflare (Fix 5)", async () => {
127
+ // No --domain, non-TTY → same hard error as `expose public --cloudflare`.
128
+ // That it reaches the cloudflare-domain check (not "unknown layer") proves
129
+ // the alias rewrote the layer to public + forced the cloudflare flag.
130
+ const { code, stderr } = await runCli(["expose", "cloudflare"]);
131
+ expect(code).toBe(1);
132
+ expect(stderr).toMatch(/--domain <hostname> is required/);
133
+ expect(stderr).not.toMatch(/unknown layer/);
134
+ });
135
+
136
+ test("expose cloudflare --domain X routes to the cloudflare path (not 'unknown layer')", async () => {
137
+ // cloudflared isn't installed under PATH="", so the cloudflare path prints
138
+ // its own not-installed hint — distinct from the layer-validation error.
139
+ const proc = Bun.spawn(
140
+ [process.execPath, CLI, "expose", "cloudflare", "--domain", "vault.example.com"],
141
+ {
142
+ stdout: "pipe",
143
+ stderr: "pipe",
144
+ env: {
145
+ ...process.env,
146
+ PATH: "",
147
+ HOME: "/tmp/parachute-hub-nonexistent-home",
148
+ PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
149
+ },
150
+ },
151
+ );
152
+ const [stdout, stderr, code] = await Promise.all([
153
+ new Response(proc.stdout).text(),
154
+ new Response(proc.stderr).text(),
155
+ proc.exited,
156
+ ]);
157
+ expect(code).toBe(1);
158
+ expect(stderr).not.toMatch(/unknown layer/);
159
+ // Reached the cloudflare path (cloudflared detection), proving the alias.
160
+ expect(stdout).toMatch(/cloudflared is not installed/);
161
+ });
162
+
126
163
  test("expose tailnet --cloudflare is rejected (cloudflare is public-only)", async () => {
127
164
  const { code, stderr } = await runCli([
128
165
  "expose",
@@ -217,11 +254,13 @@ describe("cli per-subcommand help", () => {
217
254
  expect(stderr).toMatch(/parachute install vault/);
218
255
  });
219
256
 
220
- test("vault tokens create in non-TTY passes through without prompting", async () => {
221
- // Spawned-subprocess stdio is piped, so isTtyInteractive() returns false
222
- // and the command falls through to the passthrough. Clearing PATH forces
223
- // ENOENT same probe as the `vault no-args` test. If we regressed into
224
- // prompting, this subprocess would hang on stdin instead of exiting 127.
257
+ test("vault tokens create forwards verbatim to parachute-vault", async () => {
258
+ // The guided interactive wrapper was removed with the pvt_* DROP (vault
259
+ // #412 / hub#466) `vault tokens create` now always forwards transparently
260
+ // to parachute-vault (which exits 1 with migration guidance on a real box).
261
+ // Clearing PATH forces ENOENT same probe as the `vault no-args` test.
262
+ // If we ever re-introduced a hub-side prompt, this subprocess would hang on
263
+ // stdin instead of exiting 127.
225
264
  const proc = Bun.spawn([process.execPath, CLI, "vault", "tokens", "create"], {
226
265
  stdout: "pipe",
227
266
  stderr: "pipe",
@@ -24,46 +24,53 @@ describe("is2FAEnrolled", () => {
24
24
  expect(is2FAEnrolled({ status: status({ hasTotp: false }) })).toBe(false);
25
25
  });
26
26
 
27
- test("reads totp_secret from vaultHome's config.yaml when status not supplied", () => {
27
+ test("falls back to legacy config.yaml totp_secret when hub.db is absent", () => {
28
+ // hub#473: hub.db is the source of truth for real 2FA, but a super-old
29
+ // install with no hub.db still suppresses the warning if the legacy vault
30
+ // YAML totp_secret is set. Point hubDbPath at a nonexistent file so the
31
+ // YAML fallback is exercised.
28
32
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
29
33
  try {
30
34
  writeFileSync(join(dir, "config.yaml"), 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
31
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(true);
35
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(true);
32
36
  } finally {
33
37
  rmSync(dir, { recursive: true, force: true });
34
38
  }
35
39
  });
36
40
 
37
- test("missing config.yaml → not enrolled (false)", () => {
41
+ test("missing config.yaml + absent hub.db → not enrolled (false)", () => {
38
42
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
39
43
  try {
40
- // No config.yaml written.
41
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
44
+ // No config.yaml written, no hub.db.
45
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
42
46
  } finally {
43
47
  rmSync(dir, { recursive: true, force: true });
44
48
  }
45
49
  });
46
50
 
47
- test("empty totp_secret value → not enrolled (matches vault's readGlobalConfig)", () => {
51
+ test("empty totp_secret value + absent hub.db → not enrolled", () => {
48
52
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
49
53
  try {
50
54
  writeFileSync(join(dir, "config.yaml"), 'totp_secret: ""\n');
51
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
55
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
52
56
  } finally {
53
57
  rmSync(dir, { recursive: true, force: true });
54
58
  }
55
59
  });
56
60
 
57
- test("malformed config.yaml not enrolled (safer fail mode, fires warning)", () => {
58
- // The probe parses config.yaml with a line-anchored regex (no YAML
59
- // dependency), so junk content simply doesn't match `totp_secret: "..."`
60
- // and resolves to `hasTotp: false` — which fires the public-exposure
61
- // warning rather than silently suppressing it. Pin that contract so a
62
- // future refactor of auth-status.ts can't quietly invert it.
61
+ test("hub.db with an enrolled user enrolled (the real signal)", async () => {
63
62
  const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
64
63
  try {
65
- writeFileSync(join(dir, "config.yaml"), "totp_secret: [unbalanced\n ::: not yaml\n");
66
- expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
64
+ const { hubDbPath, openHubDb } = await import("../hub-db.ts");
65
+ const { createUser } = await import("../users.ts");
66
+ const { persistEnrollment } = await import("../two-factor-store.ts");
67
+ const { generateTotpSecret } = await import("../totp.ts");
68
+ const dbPath = hubDbPath(dir);
69
+ const db = openHubDb(dbPath);
70
+ const u = await createUser(db, "owner", "owner-password-123");
71
+ await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
72
+ db.close();
73
+ expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: dbPath })).toBe(true);
67
74
  } finally {
68
75
  rmSync(dir, { recursive: true, force: true });
69
76
  }
@@ -80,9 +87,14 @@ describe("printPublic2FAWarning", () => {
80
87
  });
81
88
  expect(fired).toBe(true);
82
89
  const joined = logs.join("\n");
83
- expect(joined).toContain("2FA is not enrolled");
90
+ // hub#473: real hub-login 2FA. The warning now recommends the real
91
+ // `parachute auth 2fa enroll` path (+ the /account/2fa browser path) and
92
+ // still nudges a strong owner password.
93
+ expect(joined).toContain("/login is now reachable on the public internet");
84
94
  expect(joined).toContain("https://vault.example.com/login");
85
95
  expect(joined).toContain("parachute auth 2fa enroll");
96
+ expect(joined).toContain("/account/2fa");
97
+ expect(joined).toContain("parachute auth set-password");
86
98
  });
87
99
 
88
100
  test("enrolled → suppressed, returns false, logs nothing", () => {
@@ -108,7 +120,9 @@ describe("printPublic2FAWarning", () => {
108
120
  publicUrl: "https://vault.example.com",
109
121
  });
110
122
  expect(fired).toBe(true);
111
- expect(logs.some((l) => l.includes("2FA is not enrolled"))).toBe(true);
123
+ expect(logs.some((l) => l.includes("/login is now reachable on the public internet"))).toBe(
124
+ true,
125
+ );
112
126
  });
113
127
 
114
128
  test("embeds the supplied publicUrl into the /login pointer", () => {
@@ -40,128 +40,124 @@ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
40
40
  };
41
41
  }
42
42
 
43
- describe("runAuthPreflight — wide open (no password, no tokens)", () => {
44
- test("warns loudly and offers password, 2FA, and token creation", async () => {
45
- const h = makeHarness(["y", "n", "n"]); // password yes, 2fa no, token no
43
+ describe("runAuthPreflight — wide open (no owner password)", () => {
44
+ test("warns loudly, offers password + real 2FA enroll, prints hub-JWT token guidance", async () => {
45
+ const h = makeHarness(["y", "y"]); // password yes, 2FA yes
46
46
  await runAuthPreflight({ status: status(), ...wire(h) });
47
47
  const joined = h.logs.join("\n");
48
- expect(joined).toContain("No owner password and no API tokens");
48
+ expect(joined).toContain("No owner password");
49
49
  expect(joined).toContain("public internet");
50
- expect(h.commands).toHaveLength(1);
51
- expect(h.commands[0]).toEqual(["parachute", "auth", "set-password"]);
50
+ // Programmatic-client guidance points at the hub mint path, not pvt_*.
51
+ expect(joined).toContain("parachute auth mint-token --scope vault:default:read");
52
+ expect(joined).toContain("Bearer <hub-jwt>");
53
+ // Real 2FA (hub#473) — both commands offered + run.
54
+ expect(h.commands.map((c) => c.join(" "))).toEqual([
55
+ "parachute auth set-password",
56
+ "parachute auth 2fa enroll",
57
+ ]);
58
+ // Two interactive offers now: password + 2FA. Token guidance is printed.
59
+ expect(h.prompts).toHaveLength(2);
52
60
  });
53
61
 
54
- test("user declines every prompt no subprocesses run", async () => {
55
- const h = makeHarness(["", "", ""]); // all Enter = skip
62
+ test("token guidance uses the first discovered vault name", async () => {
63
+ const h = makeHarness(["n", "n"]);
64
+ await runAuthPreflight({ status: status({ vaultNames: ["work"] }), ...wire(h) });
65
+ expect(h.logs.join("\n")).toContain("--scope vault:work:read");
66
+ });
67
+
68
+ test("user declines both prompts → no subprocesses run", async () => {
69
+ const h = makeHarness(["", ""]); // Enter = skip both
56
70
  await runAuthPreflight({ status: status(), ...wire(h) });
57
71
  expect(h.commands).toHaveLength(0);
58
- // Still prompted on all three lines, even though each was declined.
59
- expect(h.prompts).toHaveLength(3);
72
+ expect(h.prompts).toHaveLength(2);
60
73
  });
61
74
 
62
- test("user accepts all threeall three commands invoked in order", async () => {
63
- const h = makeHarness(["y", "y", "y"]);
75
+ test("user accepts the password offer set-password invoked", async () => {
76
+ const h = makeHarness(["y", "n"]);
64
77
  await runAuthPreflight({ status: status(), ...wire(h) });
65
- expect(h.commands.map((c) => c.join(" "))).toEqual([
66
- "parachute auth set-password",
67
- "parachute auth 2fa enroll",
68
- "parachute vault tokens create",
69
- ]);
78
+ expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth set-password"]);
70
79
  });
71
- });
72
80
 
73
- describe("runAuthPreflight password set, no 2FA", () => {
74
- test("short nudge, offers 2FA only", async () => {
75
- const h = makeHarness(["y"]);
81
+ test("null tokenCount with no owner password still classifies wide-open", async () => {
82
+ // The `tokenCount: null` (unreadable vault DB) path is vestigial post-DROP
83
+ // `classify()` gates on `hasOwnerPassword` alone.
84
+ const h = makeHarness(["n", "n"]);
76
85
  await runAuthPreflight({
77
- status: status({ hasOwnerPassword: true, tokenCount: 3 }),
86
+ status: status({ hasOwnerPassword: false, tokenCount: null }),
78
87
  ...wire(h),
79
88
  });
80
89
  const joined = h.logs.join("\n");
81
- expect(joined).toContain("Owner password is set");
82
- expect(joined).toContain("2FA");
83
- expect(h.prompts).toHaveLength(1);
84
- expect(h.commands).toEqual([["parachute", "auth", "2fa", "enroll"]]);
90
+ expect(joined).toContain("No owner password");
91
+ expect(joined).toContain("public internet");
92
+ expect(h.prompts).toHaveLength(2);
85
93
  });
86
94
 
87
- test("user declines no command runs", async () => {
88
- const h = makeHarness([""]);
89
- await runAuthPreflight({
90
- status: status({ hasOwnerPassword: true, tokenCount: 3 }),
91
- ...wire(h),
92
- });
93
- expect(h.commands).toHaveLength(0);
95
+ test("never offers the dead vault-tokens-create command", async () => {
96
+ const h = makeHarness(["y", "n"]);
97
+ await runAuthPreflight({ status: status(), ...wire(h) });
98
+ const allCommands = h.commands.map((c) => c.join(" ")).join("\n");
99
+ expect(allCommands).not.toContain("vault tokens create");
100
+ const guidance = h.logs.join("\n");
101
+ expect(guidance).not.toContain("parachute vault tokens create");
94
102
  });
95
103
  });
96
104
 
97
- describe("runAuthPreflight — tokens exist, no password", () => {
98
- test("notes that OAuth is not set up, offers password", async () => {
99
- const h = makeHarness(["y"]);
105
+ describe("runAuthPreflight — password set, no 2FA", () => {
106
+ test("confirms password set + offers real 2FA enroll (ignores vestigial tokenCount)", async () => {
107
+ const h = makeHarness(["n"]); // decline 2FA
100
108
  await runAuthPreflight({
101
- status: status({ hasOwnerPassword: false, tokenCount: 2 }),
109
+ // tokenCount is non-zero (vestigial pvt_* rows) but no longer consulted.
110
+ status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: 3 }),
102
111
  ...wire(h),
103
112
  });
104
113
  const joined = h.logs.join("\n");
105
- expect(joined).toContain("API tokens exist");
106
- expect(joined).toContain("no owner password");
114
+ expect(joined).toContain("Owner password is set");
115
+ // Real 2FA offer (hub#473) — exactly one prompt (the 2FA offer).
107
116
  expect(h.prompts).toHaveLength(1);
108
- expect(h.commands).toEqual([["parachute", "auth", "set-password"]]);
117
+ expect(h.commands).toHaveLength(0); // declined
109
118
  });
110
- });
111
119
 
112
- describe("runAuthPreflight unknown token count (SQLite failed)", () => {
113
- test("advises running `tokens list`, no token-dependent prompts", async () => {
114
- const h = makeHarness([]);
120
+ test("accepting the 2FA offer runs `parachute auth 2fa enroll`", async () => {
121
+ const h = makeHarness(["y"]);
115
122
  await runAuthPreflight({
116
- status: status({ hasOwnerPassword: false, hasTotp: false, tokenCount: null }),
123
+ status: status({ hasOwnerPassword: true, hasTotp: false }),
117
124
  ...wire(h),
118
125
  });
119
- const joined = h.logs.join("\n");
120
- expect(joined).toContain("Couldn't read vault token state");
121
- expect(joined).toContain("parachute vault tokens list");
122
- // No prompts because we don't offer password/token flow when token
123
- // state is unknown (it'd be ambiguous whether we're dealing with the
124
- // wide-open or the tokens-only case).
125
- expect(h.prompts).toHaveLength(0);
126
+ expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth 2fa enroll"]);
126
127
  });
127
128
 
128
- test("password set + 2FA absent + tokens unknown still offers 2FA", async () => {
129
- const h = makeHarness([""]); // decline 2FA
129
+ test("null tokenCount (DB unreadable) is irrelevant password gates the branch", async () => {
130
+ const h = makeHarness(["n"]);
130
131
  await runAuthPreflight({
131
132
  status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
132
133
  ...wire(h),
133
134
  });
135
+ expect(h.logs.join("\n")).toContain("Owner password is set");
134
136
  expect(h.prompts).toHaveLength(1);
135
- expect(h.prompts[0]?.toLowerCase()).toContain("2fa");
136
137
  });
137
138
  });
138
139
 
139
- describe("runAuthPreflight — all good", () => {
140
- test("single positive line, no prompts", async () => {
140
+ describe("runAuthPreflight — password + 2FA both set", () => {
141
+ test("two-line confirmation, no prompts", async () => {
141
142
  const h = makeHarness([]);
142
143
  await runAuthPreflight({
143
- status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 1 }),
144
+ status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 0 }),
144
145
  ...wire(h),
145
146
  });
146
147
  const joined = h.logs.join("\n");
147
- expect(joined).toContain("looks good");
148
+ expect(joined).toContain("Owner password is set");
149
+ expect(joined).toContain("Two-factor authentication is on");
148
150
  expect(h.prompts).toHaveLength(0);
149
151
  expect(h.commands).toHaveLength(0);
150
152
  });
151
153
  });
152
154
 
153
155
  describe("runAuthPreflight — subprocess failure handling", () => {
154
- test("non-zero exit from auth command doesn't abort the rest of the preflight", async () => {
155
- const h = makeHarness(["y", "y", "y"]);
156
- // Override the interactive runner to return non-zero on the first call.
157
- let first = true;
156
+ test("non-zero exit from set-password doesn't abort the rest of the preflight", async () => {
157
+ const h = makeHarness(["y", "n"]);
158
158
  const interactiveRunner = async (cmd: readonly string[]) => {
159
159
  h.commands.push([...cmd]);
160
- if (first) {
161
- first = false;
162
- return 7;
163
- }
164
- return 0;
160
+ return 7;
165
161
  };
166
162
  await runAuthPreflight({
167
163
  status: status(),
@@ -172,19 +168,22 @@ describe("runAuthPreflight — subprocess failure handling", () => {
172
168
  },
173
169
  interactiveRunner,
174
170
  });
175
- // All three commands still attempted, none aborted the flow.
176
- expect(h.commands.map((c) => c[0])).toEqual(["parachute", "parachute", "parachute"]);
171
+ // The command was attempted; the flow continued (token guidance still
172
+ // printed afterward).
173
+ expect(h.commands.map((c) => c[0])).toEqual(["parachute"]);
177
174
  const joined = h.logs.join("\n");
178
175
  expect(joined).toContain("exited 7");
176
+ expect(joined).toContain("Bearer <hub-jwt>");
179
177
  });
180
178
  });
181
179
 
182
180
  describe("runAuthPreflight — case-insensitive yes", () => {
183
181
  test('"Y", "YES", and "y" all count as affirmative; anything else is decline', async () => {
182
+ // Drive the password-set-no-2FA path so there's exactly one prompt (2FA).
184
183
  for (const yes of ["y", "Y", "yes", "YES"]) {
185
184
  const h = makeHarness([yes]);
186
185
  await runAuthPreflight({
187
- status: status({ hasOwnerPassword: true, tokenCount: 1 }),
186
+ status: status({ hasOwnerPassword: true, hasTotp: false }),
188
187
  ...wire(h),
189
188
  });
190
189
  expect(h.commands).toHaveLength(1);
@@ -192,7 +191,7 @@ describe("runAuthPreflight — case-insensitive yes", () => {
192
191
  for (const no of ["", "n", "no", "q", "bogus"]) {
193
192
  const h = makeHarness([no]);
194
193
  await runAuthPreflight({
195
- status: status({ hasOwnerPassword: true, tokenCount: 1 }),
194
+ status: status({ hasOwnerPassword: true, hasTotp: false }),
196
195
  ...wire(h),
197
196
  });
198
197
  expect(h.commands).toHaveLength(0);