@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  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 +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -76,6 +76,42 @@ describe("status", () => {
76
76
  }
77
77
  });
78
78
 
79
+ test("persisted lastStartError surfaces on a continuation line", async () => {
80
+ const { path, cleanup } = makeTempPath();
81
+ try {
82
+ upsertService(
83
+ {
84
+ name: "parachute-vault",
85
+ port: 1940,
86
+ paths: ["/"],
87
+ health: "/health",
88
+ version: "0.2.4",
89
+ lastStartError: {
90
+ error_type: "missing_dependency",
91
+ error_description: "parachute-vault is required ...",
92
+ binary: "parachute-vault",
93
+ install: { generic: "parachute install vault" },
94
+ },
95
+ },
96
+ path,
97
+ );
98
+ const lines: string[] = [];
99
+ await status({
100
+ manifestPath: path,
101
+ // Probe refuses (service down) — the row is failing, and the
102
+ // start-error note explains why.
103
+ fetchImpl: async () => {
104
+ throw new Error("ECONNREFUSED");
105
+ },
106
+ print: (l) => lines.push(l),
107
+ });
108
+ const out = lines.join("\n");
109
+ expect(out).toMatch(/failed to start: parachute-vault not installed/);
110
+ } finally {
111
+ cleanup();
112
+ }
113
+ });
114
+
79
115
  test("any-failing returns 1 and surfaces probe detail on continuation line", async () => {
80
116
  const { path, cleanup } = makeTempPath();
81
117
  try {
@@ -0,0 +1,602 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { Database as Sqlite } from "bun:sqlite";
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import * as OTPAuth from "otpauth";
8
+ import { handleAdminLoginPost, handleAdminLoginTotpPost } from "../admin-handlers.ts";
9
+ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
10
+ import { hubDbPath, migrate, openHubDb } from "../hub-db.ts";
11
+ import { PENDING_LOGIN_COOKIE_NAME, _resetPendingLogins } from "../pending-login.ts";
12
+ import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
13
+ import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
14
+ import { _resetTotpReplayCache, generateTotpSecret } from "../totp.ts";
15
+ import { handleTwoFactorGet, handleTwoFactorPost } from "../two-factor-handlers.ts";
16
+ import {
17
+ backupCodesRemaining,
18
+ getTotpState,
19
+ isTotpEnrolled,
20
+ persistEnrollment,
21
+ } from "../two-factor-store.ts";
22
+ import { createUser } from "../users.ts";
23
+
24
+ const TEST_CSRF = "csrf-2fa-flow-token";
25
+ const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
26
+
27
+ interface Harness {
28
+ db: Database;
29
+ configDir: string;
30
+ cleanup: () => void;
31
+ }
32
+
33
+ function makeHarness(): Harness {
34
+ const configDir = mkdtempSync(join(tmpdir(), "phub-2fa-flow-"));
35
+ const db = openHubDb(hubDbPath(configDir));
36
+ return {
37
+ db,
38
+ configDir,
39
+ cleanup: () => {
40
+ db.close();
41
+ rmSync(configDir, { recursive: true, force: true });
42
+ },
43
+ };
44
+ }
45
+
46
+ function formBody(values: Record<string, string>): {
47
+ body: string;
48
+ headers: Record<string, string>;
49
+ } {
50
+ const params = new URLSearchParams();
51
+ for (const [k, v] of Object.entries(values)) params.append(k, v);
52
+ return {
53
+ body: params.toString(),
54
+ headers: { "content-type": "application/x-www-form-urlencoded" },
55
+ };
56
+ }
57
+
58
+ function liveCode(secretBase32: string, label = "owner"): string {
59
+ return new OTPAuth.TOTP({
60
+ issuer: "Parachute Hub",
61
+ label,
62
+ algorithm: "SHA1",
63
+ digits: 6,
64
+ period: 30,
65
+ secret: OTPAuth.Secret.fromBase32(secretBase32),
66
+ }).generate();
67
+ }
68
+
69
+ /** Pull the value of a Set-Cookie'd cookie by name from a Response. */
70
+ function cookieFrom(res: Response, name: string): string | null {
71
+ // Bun's Headers.getSetCookie() returns all set-cookie values.
72
+ const all = res.headers.getSetCookie();
73
+ for (const sc of all) {
74
+ const m = sc.match(new RegExp(`(?:^|; )?${name}=([^;]*)`));
75
+ if (m && sc.startsWith(`${name}=`)) return m[1] ?? "";
76
+ }
77
+ return null;
78
+ }
79
+
80
+ let harness: Harness;
81
+ beforeEach(() => {
82
+ harness = makeHarness();
83
+ resetRateLimit();
84
+ _resetPendingLogins();
85
+ _resetTotpReplayCache();
86
+ });
87
+ afterEach(() => {
88
+ harness.cleanup();
89
+ });
90
+
91
+ describe("login two-step (TOTP) — hub#473", () => {
92
+ test("password-only login UNCHANGED for a user WITHOUT 2FA", async () => {
93
+ await createUser(harness.db, "owner", "owner-password-123", { passwordChanged: true });
94
+ const { body, headers } = formBody({
95
+ [CSRF_FIELD_NAME]: TEST_CSRF,
96
+ username: "owner",
97
+ password: "owner-password-123",
98
+ next: "/admin/vaults",
99
+ });
100
+ const req = new Request("http://hub.test/login", {
101
+ method: "POST",
102
+ headers: { ...headers, cookie: CSRF_COOKIE },
103
+ body,
104
+ });
105
+ const res = await handleAdminLoginPost(harness.db, req);
106
+ // Straight to session — no 2FA challenge.
107
+ expect(res.status).toBe(302);
108
+ expect(res.headers.get("location")).toBe("/admin/vaults");
109
+ expect(cookieFrom(res, "parachute_hub_session")).toBeTruthy();
110
+ });
111
+
112
+ test("correct password for a 2FA user → challenge page + pending cookie, NO session yet", async () => {
113
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
114
+ passwordChanged: true,
115
+ });
116
+ await persistEnrollment(harness.db, u.id, generateTotpSecret("owner").secret);
117
+ const { body, headers } = formBody({
118
+ [CSRF_FIELD_NAME]: TEST_CSRF,
119
+ username: "owner",
120
+ password: "owner-password-123",
121
+ next: "/admin/vaults",
122
+ });
123
+ const req = new Request("http://hub.test/login", {
124
+ method: "POST",
125
+ headers: { ...headers, cookie: CSRF_COOKIE },
126
+ body,
127
+ });
128
+ const res = await handleAdminLoginPost(harness.db, req);
129
+ expect(res.status).toBe(200);
130
+ const html = await res.text();
131
+ expect(html).toContain("Two-factor authentication");
132
+ // Pending-login cookie minted; session NOT minted.
133
+ expect(cookieFrom(res, PENDING_LOGIN_COOKIE_NAME)).toBeTruthy();
134
+ expect(cookieFrom(res, "parachute_hub_session")).toBeNull();
135
+ });
136
+
137
+ test("full two-step: password → challenge → correct TOTP → session minted", async () => {
138
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
139
+ passwordChanged: true,
140
+ });
141
+ const { secret } = generateTotpSecret("owner");
142
+ await persistEnrollment(harness.db, u.id, secret);
143
+
144
+ // Step 1 — password.
145
+ const pw = formBody({
146
+ [CSRF_FIELD_NAME]: TEST_CSRF,
147
+ username: "owner",
148
+ password: "owner-password-123",
149
+ next: "/admin/tokens",
150
+ });
151
+ const pwReq = new Request("http://hub.test/login", {
152
+ method: "POST",
153
+ headers: { ...pw.headers, cookie: CSRF_COOKIE },
154
+ body: pw.body,
155
+ });
156
+ const pwRes = await handleAdminLoginPost(harness.db, pwReq);
157
+ expect(pwRes.status).toBe(200);
158
+ const pendingToken = cookieFrom(pwRes, PENDING_LOGIN_COOKIE_NAME);
159
+ expect(pendingToken).toBeTruthy();
160
+
161
+ // Step 2 — TOTP code with the pending cookie.
162
+ const code = liveCode(secret);
163
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code, next: "/admin/tokens" });
164
+ const tfReq = new Request("http://hub.test/login/2fa", {
165
+ method: "POST",
166
+ headers: {
167
+ ...tf.headers,
168
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
169
+ },
170
+ body: tf.body,
171
+ });
172
+ const tfRes = await handleAdminLoginTotpPost(harness.db, tfReq);
173
+ expect(tfRes.status).toBe(302);
174
+ expect(tfRes.headers.get("location")).toBe("/admin/tokens");
175
+ const sessionCookie = cookieFrom(tfRes, "parachute_hub_session");
176
+ expect(sessionCookie).toBeTruthy();
177
+ // The session is real.
178
+ expect(findSession(harness.db, sessionCookie!)).not.toBeNull();
179
+ });
180
+
181
+ test("wrong TOTP code → 401, no session; pending login survives for retry", async () => {
182
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
183
+ passwordChanged: true,
184
+ });
185
+ const { secret } = generateTotpSecret("owner");
186
+ await persistEnrollment(harness.db, u.id, secret);
187
+
188
+ const pw = formBody({
189
+ [CSRF_FIELD_NAME]: TEST_CSRF,
190
+ username: "owner",
191
+ password: "owner-password-123",
192
+ next: "/admin/vaults",
193
+ });
194
+ const pwRes = await handleAdminLoginPost(
195
+ harness.db,
196
+ new Request("http://hub.test/login", {
197
+ method: "POST",
198
+ headers: { ...pw.headers, cookie: CSRF_COOKIE },
199
+ body: pw.body,
200
+ }),
201
+ );
202
+ const pendingToken = cookieFrom(pwRes, PENDING_LOGIN_COOKIE_NAME)!;
203
+
204
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
205
+ const tfRes = await handleAdminLoginTotpPost(
206
+ harness.db,
207
+ new Request("http://hub.test/login/2fa", {
208
+ method: "POST",
209
+ headers: {
210
+ ...tf.headers,
211
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
212
+ },
213
+ body: tf.body,
214
+ }),
215
+ );
216
+ expect(tfRes.status).toBe(401);
217
+ expect(cookieFrom(tfRes, "parachute_hub_session")).toBeNull();
218
+
219
+ // Retry with the correct code against the SAME pending login → success.
220
+ const code = liveCode(secret);
221
+ const retry = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code, next: "/admin/vaults" });
222
+ const retryRes = await handleAdminLoginTotpPost(
223
+ harness.db,
224
+ new Request("http://hub.test/login/2fa", {
225
+ method: "POST",
226
+ headers: {
227
+ ...retry.headers,
228
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
229
+ },
230
+ body: retry.body,
231
+ }),
232
+ );
233
+ expect(retryRes.status).toBe(302);
234
+ expect(cookieFrom(retryRes, "parachute_hub_session")).toBeTruthy();
235
+ });
236
+
237
+ test("a valid backup code completes the second step + is consumed", async () => {
238
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
239
+ passwordChanged: true,
240
+ });
241
+ const { secret } = generateTotpSecret("owner");
242
+ const { backupCodes } = await persistEnrollment(harness.db, u.id, secret);
243
+ expect(backupCodesRemaining(harness.db, u.id)).toBe(10);
244
+
245
+ const pw = formBody({
246
+ [CSRF_FIELD_NAME]: TEST_CSRF,
247
+ username: "owner",
248
+ password: "owner-password-123",
249
+ next: "/admin/vaults",
250
+ });
251
+ const pwRes = await handleAdminLoginPost(
252
+ harness.db,
253
+ new Request("http://hub.test/login", {
254
+ method: "POST",
255
+ headers: { ...pw.headers, cookie: CSRF_COOKIE },
256
+ body: pw.body,
257
+ }),
258
+ );
259
+ const pendingToken = cookieFrom(pwRes, PENDING_LOGIN_COOKIE_NAME)!;
260
+
261
+ const tf = formBody({
262
+ [CSRF_FIELD_NAME]: TEST_CSRF,
263
+ code: backupCodes[0]!,
264
+ next: "/admin/vaults",
265
+ });
266
+ const tfRes = await handleAdminLoginTotpPost(
267
+ harness.db,
268
+ new Request("http://hub.test/login/2fa", {
269
+ method: "POST",
270
+ headers: {
271
+ ...tf.headers,
272
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
273
+ },
274
+ body: tf.body,
275
+ }),
276
+ );
277
+ expect(tfRes.status).toBe(302);
278
+ expect(cookieFrom(tfRes, "parachute_hub_session")).toBeTruthy();
279
+ // Consumed — one fewer remaining.
280
+ expect(backupCodesRemaining(harness.db, u.id)).toBe(9);
281
+ });
282
+
283
+ test("2FA step without a pending-login cookie → 401 (can't skip the password step)", async () => {
284
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
285
+ passwordChanged: true,
286
+ });
287
+ await persistEnrollment(harness.db, u.id, generateTotpSecret("owner").secret);
288
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "123456", next: "/admin/vaults" });
289
+ const res = await handleAdminLoginTotpPost(
290
+ harness.db,
291
+ new Request("http://hub.test/login/2fa", {
292
+ method: "POST",
293
+ headers: { ...tf.headers, cookie: CSRF_COOKIE },
294
+ body: tf.body,
295
+ }),
296
+ );
297
+ expect(res.status).toBe(401);
298
+ expect(cookieFrom(res, "parachute_hub_session")).toBeNull();
299
+ });
300
+
301
+ test("2FA step CSRF mismatch → 400", async () => {
302
+ const tf = formBody({ [CSRF_FIELD_NAME]: "wrong", code: "123456", next: "/admin/vaults" });
303
+ const res = await handleAdminLoginTotpPost(
304
+ harness.db,
305
+ new Request("http://hub.test/login/2fa", {
306
+ method: "POST",
307
+ headers: { ...tf.headers, cookie: CSRF_COOKIE },
308
+ body: tf.body,
309
+ }),
310
+ );
311
+ expect(res.status).toBe(400);
312
+ });
313
+
314
+ test("2FA step is rate-limited per IP (6th attempt → 429)", async () => {
315
+ const u = await createUser(harness.db, "owner", "owner-password-123", {
316
+ passwordChanged: true,
317
+ });
318
+ await persistEnrollment(harness.db, u.id, generateTotpSecret("owner").secret);
319
+ const buildReq = () => {
320
+ const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
321
+ return new Request("http://hub.test/login/2fa", {
322
+ method: "POST",
323
+ headers: { ...tf.headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.55" },
324
+ body: tf.body,
325
+ });
326
+ };
327
+ for (let i = 0; i < 5; i++) {
328
+ const r = await handleAdminLoginTotpPost(harness.db, buildReq());
329
+ expect(r.status).toBe(401); // no pending login → 401, but counts toward bucket
330
+ }
331
+ const denied = await handleAdminLoginTotpPost(harness.db, buildReq());
332
+ expect(denied.status).toBe(429);
333
+ expect(denied.headers.get("retry-after")).not.toBeNull();
334
+ });
335
+ });
336
+
337
+ describe("/account/2fa handlers — hub#473", () => {
338
+ async function signedInUser(username = "owner"): Promise<{ id: string; cookie: string }> {
339
+ const u = await createUser(harness.db, username, "owner-password-123", {
340
+ passwordChanged: true,
341
+ });
342
+ const session = createSession(harness.db, { userId: u.id });
343
+ return {
344
+ id: u.id,
345
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
346
+ };
347
+ }
348
+
349
+ test("GET requires a session — redirects to /login when absent", () => {
350
+ const res = handleTwoFactorGet(new Request("http://hub.test/account/2fa"), { db: harness.db });
351
+ expect(res.status).toBe(302);
352
+ expect(res.headers.get("location")).toContain("/login");
353
+ });
354
+
355
+ test("GET (not enrolled) renders the set-up CTA", async () => {
356
+ const { cookie } = await signedInUser();
357
+ const res = handleTwoFactorGet(
358
+ new Request("http://hub.test/account/2fa", { headers: { cookie } }),
359
+ { db: harness.db },
360
+ );
361
+ expect(res.status).toBe(200);
362
+ const html = await res.text();
363
+ expect(html).toContain("Set up two-factor authentication");
364
+ });
365
+
366
+ test("POST start → enrolling page with a QR svg + manual secret; nothing persisted yet", async () => {
367
+ const { id, cookie } = await signedInUser();
368
+ const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, action: "start" });
369
+ const res = await handleTwoFactorPost(
370
+ new Request("http://hub.test/account/2fa", {
371
+ method: "POST",
372
+ headers: { ...headers, cookie },
373
+ body,
374
+ }),
375
+ { db: harness.db },
376
+ );
377
+ expect(res.status).toBe(200);
378
+ const html = await res.text();
379
+ expect(html).toContain("<svg");
380
+ expect(html).toContain('data-testid="totp-secret"');
381
+ // Not persisted until confirm.
382
+ expect(isTotpEnrolled(harness.db, id)).toBe(false);
383
+ });
384
+
385
+ test("POST confirm with a live code persists enrollment + shows backup codes once", async () => {
386
+ const { id, cookie } = await signedInUser();
387
+ const { secret } = generateTotpSecret("owner");
388
+ const { body, headers } = formBody({
389
+ [CSRF_FIELD_NAME]: TEST_CSRF,
390
+ action: "confirm",
391
+ secret,
392
+ code: liveCode(secret),
393
+ });
394
+ const res = await handleTwoFactorPost(
395
+ new Request("http://hub.test/account/2fa", {
396
+ method: "POST",
397
+ headers: { ...headers, cookie },
398
+ body,
399
+ }),
400
+ { db: harness.db },
401
+ );
402
+ expect(res.status).toBe(200);
403
+ const html = await res.text();
404
+ expect(html).toContain('data-testid="backup-codes"');
405
+ expect(res.headers.get("cache-control")).toBe("no-store");
406
+ // Persisted.
407
+ expect(isTotpEnrolled(harness.db, id)).toBe(true);
408
+ expect(getTotpState(harness.db, id).secret).toBe(secret);
409
+ expect(backupCodesRemaining(harness.db, id)).toBe(10);
410
+ });
411
+
412
+ test("POST confirm with a WRONG code re-renders the enrolling page; nothing persisted", async () => {
413
+ const { id, cookie } = await signedInUser();
414
+ const { secret } = generateTotpSecret("owner");
415
+ const { body, headers } = formBody({
416
+ [CSRF_FIELD_NAME]: TEST_CSRF,
417
+ action: "confirm",
418
+ secret,
419
+ code: "000000",
420
+ });
421
+ const res = await handleTwoFactorPost(
422
+ new Request("http://hub.test/account/2fa", {
423
+ method: "POST",
424
+ headers: { ...headers, cookie },
425
+ body,
426
+ }),
427
+ { db: harness.db },
428
+ );
429
+ expect(res.status).toBe(200);
430
+ const html = await res.text();
431
+ // Apostrophe is HTML-escaped in the rendered banner.
432
+ expect(html).toContain("match");
433
+ expect(html).toContain("error-banner");
434
+ expect(isTotpEnrolled(harness.db, id)).toBe(false);
435
+ });
436
+
437
+ test("POST confirm with a MALFORMED secret → 400, nothing persisted (N1 guard)", async () => {
438
+ const { id, cookie } = await signedInUser();
439
+ // Non-base32 / too-short secret from a tampered or truncated form.
440
+ for (const badSecret of ["not-base32!", "ABC123", "", "0189{}<>"]) {
441
+ const { body, headers } = formBody({
442
+ [CSRF_FIELD_NAME]: TEST_CSRF,
443
+ action: "confirm",
444
+ secret: badSecret,
445
+ code: "123456",
446
+ });
447
+ const res = await handleTwoFactorPost(
448
+ new Request("http://hub.test/account/2fa", {
449
+ method: "POST",
450
+ headers: { ...headers, cookie },
451
+ body,
452
+ }),
453
+ { db: harness.db },
454
+ );
455
+ expect(res.status).toBe(400);
456
+ expect(isTotpEnrolled(harness.db, id)).toBe(false);
457
+ }
458
+ });
459
+
460
+ test("POST disable requires the correct current password; clears 2FA on success", async () => {
461
+ const { id, cookie } = await signedInUser();
462
+ await persistEnrollment(harness.db, id, generateTotpSecret("owner").secret);
463
+ expect(isTotpEnrolled(harness.db, id)).toBe(true);
464
+
465
+ // Wrong password → 401, still enrolled.
466
+ const bad = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, action: "disable", password: "nope" });
467
+ const badRes = await handleTwoFactorPost(
468
+ new Request("http://hub.test/account/2fa", {
469
+ method: "POST",
470
+ headers: { ...bad.headers, cookie },
471
+ body: bad.body,
472
+ }),
473
+ { db: harness.db },
474
+ );
475
+ expect(badRes.status).toBe(401);
476
+ expect(isTotpEnrolled(harness.db, id)).toBe(true);
477
+
478
+ // Correct password → 302, cleared.
479
+ const ok = formBody({
480
+ [CSRF_FIELD_NAME]: TEST_CSRF,
481
+ action: "disable",
482
+ password: "owner-password-123",
483
+ });
484
+ const okRes = await handleTwoFactorPost(
485
+ new Request("http://hub.test/account/2fa", {
486
+ method: "POST",
487
+ headers: { ...ok.headers, cookie },
488
+ body: ok.body,
489
+ }),
490
+ { db: harness.db },
491
+ );
492
+ expect(okRes.status).toBe(302);
493
+ expect(okRes.headers.get("location")).toContain("/account/2fa");
494
+ expect(isTotpEnrolled(harness.db, id)).toBe(false);
495
+ });
496
+
497
+ test("POST start refuses when already enrolled (409)", async () => {
498
+ const { id, cookie } = await signedInUser();
499
+ await persistEnrollment(harness.db, id, generateTotpSecret("owner").secret);
500
+ const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, action: "start" });
501
+ const res = await handleTwoFactorPost(
502
+ new Request("http://hub.test/account/2fa", {
503
+ method: "POST",
504
+ headers: { ...headers, cookie },
505
+ body,
506
+ }),
507
+ { db: harness.db },
508
+ );
509
+ expect(res.status).toBe(409);
510
+ });
511
+
512
+ test("POST CSRF mismatch → 400", async () => {
513
+ const { cookie } = await signedInUser();
514
+ const { body, headers } = formBody({ [CSRF_FIELD_NAME]: "wrong", action: "start" });
515
+ const res = await handleTwoFactorPost(
516
+ new Request("http://hub.test/account/2fa", {
517
+ method: "POST",
518
+ headers: { ...headers, cookie },
519
+ body,
520
+ }),
521
+ { db: harness.db },
522
+ );
523
+ expect(res.status).toBe(400);
524
+ });
525
+ });
526
+
527
+ describe("migration v11 — existing-DB safety", () => {
528
+ test("applies cleanly on a hub.db built at v10; pre-existing users keep NULL totp + password-only login", async () => {
529
+ const configDir = mkdtempSync(join(tmpdir(), "phub-2fa-mig-"));
530
+ try {
531
+ const dbPath = hubDbPath(configDir);
532
+ // Build a DB at the OLD schema (only migrations <= 10 applied), with a
533
+ // pre-existing user, simulating an install from before hub#473.
534
+ {
535
+ const old = new Sqlite(dbPath);
536
+ old.exec("PRAGMA journal_mode = WAL");
537
+ old.exec("PRAGMA foreign_keys = ON");
538
+ // Run the real migrator but stop it from seeing v11 by faking the
539
+ // schema_version table: apply through v10 only via a manual replay is
540
+ // brittle. Instead, run the full migrator (which includes v11) but
541
+ // assert the column is nullable + existing rows are NULL. To exercise
542
+ // the "user predates v11" path we insert via the v2-shaped INSERT and
543
+ // confirm the totp columns default NULL.
544
+ migrate(old);
545
+ // Insert a user the way createUser would, but WITHOUT touching totp.
546
+ const now = new Date().toISOString();
547
+ old
548
+ .prepare(
549
+ "INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, 1)",
550
+ )
551
+ .run("legacy-1", "legacy", "$argon2id$fakehash", now, now);
552
+ old.close();
553
+ }
554
+
555
+ // Re-open (runs migrate() again — idempotent) and verify the legacy row
556
+ // has NULL totp state → not enrolled.
557
+ const db = openHubDb(dbPath);
558
+ try {
559
+ const state = getTotpState(db, "legacy-1");
560
+ expect(state.secret).toBeNull();
561
+ expect(state.backupCodes).toEqual([]);
562
+ expect(isTotpEnrolled(db, "legacy-1")).toBe(false);
563
+
564
+ // Password-only login still works (no 2FA challenge) — verified by the
565
+ // login handler taking the password-only branch for a NULL-totp user.
566
+ // (Covered end-to-end above; here we just assert the not-enrolled
567
+ // predicate the login handler branches on.)
568
+ expect(isTotpEnrolled(db, "legacy-1")).toBe(false);
569
+ } finally {
570
+ db.close();
571
+ }
572
+ } finally {
573
+ rmSync(configDir, { recursive: true, force: true });
574
+ }
575
+ });
576
+
577
+ test("schema_version records v11", () => {
578
+ const configDir = mkdtempSync(join(tmpdir(), "phub-2fa-mig2-"));
579
+ try {
580
+ const db = openHubDb(hubDbPath(configDir));
581
+ try {
582
+ const versions = (
583
+ db.query("SELECT version FROM schema_version ORDER BY version").all() as {
584
+ version: number;
585
+ }[]
586
+ ).map((r) => r.version);
587
+ expect(versions).toContain(11);
588
+ // The totp columns exist on `users`.
589
+ const cols = (db.query("PRAGMA table_info(users)").all() as { name: string }[]).map(
590
+ (c) => c.name,
591
+ );
592
+ expect(cols).toContain("totp_secret");
593
+ expect(cols).toContain("totp_backup_codes");
594
+ expect(cols).toContain("totp_enrolled_at");
595
+ } finally {
596
+ db.close();
597
+ }
598
+ } finally {
599
+ rmSync(configDir, { recursive: true, force: true });
600
+ }
601
+ });
602
+ });