@openparachute/hub 0.5.10-rc.9 → 0.5.10

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +74 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-settings.test.ts +152 -0
  10. package/src/__tests__/jwt-sign.test.ts +59 -0
  11. package/src/__tests__/oauth-handlers.test.ts +912 -10
  12. package/src/__tests__/oauth-ui.test.ts +210 -0
  13. package/src/__tests__/scope-explanations.test.ts +23 -0
  14. package/src/__tests__/serve.test.ts +8 -1
  15. package/src/__tests__/setup-wizard.test.ts +216 -3
  16. package/src/__tests__/users.test.ts +196 -0
  17. package/src/__tests__/vault-names.test.ts +172 -0
  18. package/src/account-change-password-ui.ts +379 -0
  19. package/src/admin-handlers.ts +68 -2
  20. package/src/admin-host-admin-token.ts +5 -0
  21. package/src/admin-vault-admin-token.ts +7 -0
  22. package/src/api-account.ts +443 -0
  23. package/src/api-mint-token.ts +6 -0
  24. package/src/api-modules-ops.ts +15 -6
  25. package/src/api-modules.ts +101 -0
  26. package/src/api-users.ts +393 -0
  27. package/src/commands/auth.ts +10 -1
  28. package/src/commands/serve.ts +5 -1
  29. package/src/cors.ts +263 -0
  30. package/src/hub-db.ts +30 -0
  31. package/src/hub-server.ts +138 -18
  32. package/src/hub-settings.ts +98 -1
  33. package/src/jwt-sign.ts +17 -1
  34. package/src/oauth-handlers.ts +237 -29
  35. package/src/oauth-ui.ts +451 -38
  36. package/src/operator-token.ts +4 -0
  37. package/src/scope-explanations.ts +26 -1
  38. package/src/setup-wizard.ts +134 -16
  39. package/src/users.ts +210 -3
  40. package/src/vault-names.ts +57 -0
  41. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  42. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  43. package/web/ui/dist/index.html +2 -2
  44. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.10-rc.9",
3
+ "version": "0.5.10",
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": {
@@ -151,7 +151,7 @@ describe("handleAdminLoginPost", () => {
151
151
  });
152
152
 
153
153
  test("redirects to next= and sets session cookie on success", async () => {
154
- await createUser(harness.db, "admin", "pw");
154
+ await createUser(harness.db, "admin", "pw", { passwordChanged: true });
155
155
  const { body, headers } = formBody({
156
156
  [CSRF_FIELD_NAME]: TEST_CSRF,
157
157
  username: "admin",
@@ -170,7 +170,7 @@ describe("handleAdminLoginPost", () => {
170
170
  });
171
171
 
172
172
  test("ignores an absolute-URL next= from the form", async () => {
173
- await createUser(harness.db, "admin", "pw");
173
+ await createUser(harness.db, "admin", "pw", { passwordChanged: true });
174
174
  const { body, headers } = formBody({
175
175
  [CSRF_FIELD_NAME]: TEST_CSRF,
176
176
  username: "admin",
@@ -193,7 +193,7 @@ describe("handleAdminLoginPost", () => {
193
193
  // alongside scheme-absolute URLs; this test pins the POST path
194
194
  // explicitly so future refactors of the redirect builder don't quietly
195
195
  // re-open the open-redirect.
196
- await createUser(harness.db, "admin", "pw");
196
+ await createUser(harness.db, "admin", "pw", { passwordChanged: true });
197
197
  const { body, headers } = formBody({
198
198
  [CSRF_FIELD_NAME]: TEST_CSRF,
199
199
  username: "admin",
@@ -214,7 +214,7 @@ describe("handleAdminLoginPost", () => {
214
214
  // Post-SPA-rework default: when the form omits `next`, login lands on
215
215
  // the SPA's vault list. Previously the legacy `/admin/config` portal
216
216
  // was the default; that page is retired and 301s to /admin/vaults.
217
- await createUser(harness.db, "admin", "pw");
217
+ await createUser(harness.db, "admin", "pw", { passwordChanged: true });
218
218
  const { body, headers } = formBody({
219
219
  [CSRF_FIELD_NAME]: TEST_CSRF,
220
220
  username: "admin",
@@ -232,7 +232,7 @@ describe("handleAdminLoginPost", () => {
232
232
 
233
233
  // hub#185 — per-IP rate-limit (5 attempts / 15 min) on POST /admin/login.
234
234
  test("6 rapid POSTs from same IP get 200/401×4/429 and the 429 carries Retry-After", async () => {
235
- await createUser(harness.db, "admin", "correct-pw");
235
+ await createUser(harness.db, "admin", "correct-pw", { passwordChanged: true });
236
236
  const buildReq = (password: string) => {
237
237
  const { body, headers } = formBody({
238
238
  [CSRF_FIELD_NAME]: TEST_CSRF,
@@ -266,7 +266,7 @@ describe("handleAdminLoginPost", () => {
266
266
  });
267
267
 
268
268
  test("rate-limit is per-IP: a different IP can still log in after another's bucket fills", async () => {
269
- await createUser(harness.db, "admin", "pw");
269
+ await createUser(harness.db, "admin", "pw", { passwordChanged: true });
270
270
  const buildReq = (ip: string, password: string) => {
271
271
  const { body, headers } = formBody({
272
272
  [CSRF_FIELD_NAME]: TEST_CSRF,
@@ -316,6 +316,141 @@ describe("handleAdminLoginPost", () => {
316
316
  expect(denied.status).toBe(429);
317
317
  expect(await denied.text()).toContain("Too many login attempts");
318
318
  });
319
+
320
+ test("password too long (> PASSWORD_MAX_LEN) → 413, fires before argonVerify (PR-3 fold N1)", async () => {
321
+ // Fold N1: an unauthenticated POST submitting a megabyte password
322
+ // would otherwise burn a full argon2id verify on arbitrary input.
323
+ // The cap fires before getUserByUsername / verifyPassword — pin with
324
+ // an elapsed-time floor of 200ms.
325
+ await createUser(harness.db, "admin", "correct-pw", { passwordChanged: true });
326
+ const huge = "z".repeat(5000);
327
+ const { body, headers } = formBody({
328
+ [CSRF_FIELD_NAME]: TEST_CSRF,
329
+ username: "admin",
330
+ password: huge,
331
+ next: "/admin/vaults",
332
+ });
333
+ const req = new Request("http://hub.test/login", {
334
+ method: "POST",
335
+ headers: { ...headers, cookie: CSRF_COOKIE },
336
+ body,
337
+ });
338
+ const t0 = Date.now();
339
+ const res = await handleAdminLoginPost(harness.db, req);
340
+ const elapsed = Date.now() - t0;
341
+ expect(res.status).toBe(413);
342
+ expect(elapsed).toBeLessThan(200);
343
+ });
344
+ });
345
+
346
+ describe("loginRedirectTarget — force-change-password (multi-user PR 3)", () => {
347
+ // Multi-user Phase 1 PR 3: when a user's `password_changed === false`, the
348
+ // login POST redirects to `/account/change-password` instead of `next`.
349
+ // Session cookie is still minted (the user IS authenticated). When the
350
+ // user has `password_changed === true`, today's behavior is unchanged.
351
+ //
352
+ // We exercise the live POST handler rather than the helper directly so
353
+ // the test pins the wire-shape behavior — the helper is an
354
+ // implementation detail.
355
+
356
+ test("password_changed=false redirects to /account/change-password (admin-created user)", async () => {
357
+ // Admin-created users land with passwordChanged=false. PR 2's
358
+ // /api/users POST is the canonical entry; here we just exercise the
359
+ // helper directly to stay focused on the login redirect.
360
+ await createUser(harness.db, "admin", "admin-original-pw", {
361
+ passwordChanged: true, // wizard admin
362
+ });
363
+ await createUser(harness.db, "newbie", "default-pw", {
364
+ allowMulti: true,
365
+ passwordChanged: false, // PR-2 admin-created shape
366
+ });
367
+ const { body, headers } = formBody({
368
+ [CSRF_FIELD_NAME]: TEST_CSRF,
369
+ username: "newbie",
370
+ password: "default-pw",
371
+ next: "/admin/permissions",
372
+ });
373
+ const req = new Request("http://hub.test/login", {
374
+ method: "POST",
375
+ headers: { ...headers, cookie: CSRF_COOKIE },
376
+ body,
377
+ });
378
+ const res = await handleAdminLoginPost(harness.db, req);
379
+ expect(res.status).toBe(302);
380
+ const location = res.headers.get("location") ?? "";
381
+ // The change-password target preserves the original `next` so the
382
+ // user lands where they intended after picking a new password.
383
+ expect(location.startsWith("/account/change-password")).toBe(true);
384
+ expect(location).toContain(encodeURIComponent("/admin/permissions"));
385
+ // Session cookie IS minted — the user is signed in. The redirect is
386
+ // session-level, not "block sign-in entirely."
387
+ expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
388
+ });
389
+
390
+ test("password_changed=false with no next= redirects to bare /account/change-password", async () => {
391
+ await createUser(harness.db, "newbie", "default-pw", { passwordChanged: false });
392
+ const { body, headers } = formBody({
393
+ [CSRF_FIELD_NAME]: TEST_CSRF,
394
+ username: "newbie",
395
+ password: "default-pw",
396
+ });
397
+ const req = new Request("http://hub.test/login", {
398
+ method: "POST",
399
+ headers: { ...headers, cookie: CSRF_COOKIE },
400
+ body,
401
+ });
402
+ const res = await handleAdminLoginPost(harness.db, req);
403
+ expect(res.status).toBe(302);
404
+ // No `?next=` suffix when the user's intended destination was the
405
+ // post-login default — keeps the URL clean.
406
+ expect(res.headers.get("location")).toBe("/account/change-password");
407
+ });
408
+
409
+ test("password_changed=true honors the original next= (existing behavior unchanged)", async () => {
410
+ // Wizard admins and env-seeded admins land passwordChanged=true; the
411
+ // pre-PR-3 redirect shape must keep working.
412
+ await createUser(harness.db, "admin", "pw", { passwordChanged: true });
413
+ const { body, headers } = formBody({
414
+ [CSRF_FIELD_NAME]: TEST_CSRF,
415
+ username: "admin",
416
+ password: "pw",
417
+ next: "/admin/tokens",
418
+ });
419
+ const req = new Request("http://hub.test/login", {
420
+ method: "POST",
421
+ headers: { ...headers, cookie: CSRF_COOKIE },
422
+ body,
423
+ });
424
+ const res = await handleAdminLoginPost(harness.db, req);
425
+ expect(res.status).toBe(302);
426
+ expect(res.headers.get("location")).toBe("/admin/tokens");
427
+ });
428
+
429
+ test("password_changed=false defense-in-depth: unsafe next= is sanitized before encoding", async () => {
430
+ // safeNext rewrites unsafe next values to /admin/vaults BEFORE the
431
+ // redirect-target helper runs. The change-password URL should never
432
+ // carry an attacker-shaped redirect — even on the force-change path.
433
+ await createUser(harness.db, "newbie", "default-pw", { passwordChanged: false });
434
+ const { body, headers } = formBody({
435
+ [CSRF_FIELD_NAME]: TEST_CSRF,
436
+ username: "newbie",
437
+ password: "default-pw",
438
+ next: "https://evil.example/pwn",
439
+ });
440
+ const req = new Request("http://hub.test/login", {
441
+ method: "POST",
442
+ headers: { ...headers, cookie: CSRF_COOKIE },
443
+ body,
444
+ });
445
+ const res = await handleAdminLoginPost(harness.db, req);
446
+ expect(res.status).toBe(302);
447
+ // safeNext rewrote next to /admin/vaults (the post-login default),
448
+ // and loginRedirectTarget treats that as "no specific next" → bare
449
+ // /account/change-password. Either way, evil.example never appears.
450
+ const location = res.headers.get("location") ?? "";
451
+ expect(location.startsWith("/account/change-password")).toBe(true);
452
+ expect(location).not.toContain("evil.example");
453
+ });
319
454
  });
320
455
 
321
456
  describe("handleAdminLogoutPost (#113)", () => {
@@ -0,0 +1,463 @@
1
+ /**
2
+ * `/account/change-password` GET + POST — multi-user Phase 1 PR 3.
3
+ *
4
+ * Coverage:
5
+ * - GET without session → 302 /login (with `next` preserved)
6
+ * - GET with session, passwordChanged=false → 200 with "First-time" heading
7
+ * - GET with session, passwordChanged=true → 200 with "Change your password" heading
8
+ * - POST without session → 401
9
+ * - POST without CSRF → 400
10
+ * - POST happy path: hash updated + password_changed flips to 1
11
+ * - POST wrong current → 401
12
+ * - POST new too short (< 12) → 400
13
+ * - POST new too long (> PASSWORD_MAX_LEN) → 413 with timing pin
14
+ * - POST new !== confirm → 400
15
+ * - POST new === current → 400 (after verify, to avoid leaking that
16
+ * `new_password` matches any *attempted* current)
17
+ * - POST with passwordChanged=false: flag flips AND the page works
18
+ * normally (the only flag-gated behavior is the /login redirect)
19
+ */
20
+ import type { Database } from "bun:sqlite";
21
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
+ import { mkdtempSync, rmSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import {
26
+ handleAccountChangePasswordGet,
27
+ handleAccountChangePasswordPost,
28
+ markPasswordChanged,
29
+ } from "../api-account.ts";
30
+ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
31
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
32
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
33
+ import { createUser, getUserById, verifyPassword } from "../users.ts";
34
+
35
+ const TEST_CSRF = "csrf-account-test-token";
36
+ const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
37
+
38
+ interface Harness {
39
+ db: Database;
40
+ configDir: string;
41
+ cleanup: () => void;
42
+ }
43
+
44
+ function makeHarness(): Harness {
45
+ const configDir = mkdtempSync(join(tmpdir(), "phub-api-account-"));
46
+ const db = openHubDb(hubDbPath(configDir));
47
+ return {
48
+ db,
49
+ configDir,
50
+ cleanup: () => {
51
+ db.close();
52
+ rmSync(configDir, { recursive: true, force: true });
53
+ },
54
+ };
55
+ }
56
+
57
+ async function sessionCookieFor(
58
+ db: Database,
59
+ username: string,
60
+ password: string,
61
+ opts: { passwordChanged?: boolean; allowMulti?: boolean } = {},
62
+ ): Promise<{ userId: string; cookie: string }> {
63
+ const user = await createUser(db, username, password, {
64
+ passwordChanged: opts.passwordChanged ?? false,
65
+ allowMulti: opts.allowMulti ?? false,
66
+ });
67
+ const session = createSession(db, { userId: user.id });
68
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
69
+ return { userId: user.id, cookie };
70
+ }
71
+
72
+ function formBody(values: Record<string, string>): {
73
+ body: string;
74
+ headers: Record<string, string>;
75
+ } {
76
+ const params = new URLSearchParams();
77
+ for (const [k, v] of Object.entries(values)) params.append(k, v);
78
+ return {
79
+ body: params.toString(),
80
+ headers: { "content-type": "application/x-www-form-urlencoded" },
81
+ };
82
+ }
83
+
84
+ let harness: Harness;
85
+ beforeEach(() => {
86
+ harness = makeHarness();
87
+ });
88
+ afterEach(() => {
89
+ harness.cleanup();
90
+ });
91
+
92
+ describe("GET /account/change-password", () => {
93
+ test("no session → 302 /login with next preserved", () => {
94
+ const req = new Request("http://hub.test/account/change-password");
95
+ const res = handleAccountChangePasswordGet(req, { db: harness.db });
96
+ expect(res.status).toBe(302);
97
+ const loc = res.headers.get("location") ?? "";
98
+ expect(loc.startsWith("/login?next=")).toBe(true);
99
+ // The encoded next should bring the user back to this page.
100
+ expect(decodeURIComponent(loc.split("?next=")[1] ?? "")).toBe("/account/change-password");
101
+ });
102
+
103
+ test("no session with a downstream next= bounces through /login carrying both legs", () => {
104
+ const req = new Request("http://hub.test/account/change-password?next=%2Fadmin%2Fpermissions");
105
+ const res = handleAccountChangePasswordGet(req, { db: harness.db });
106
+ expect(res.status).toBe(302);
107
+ const loc = res.headers.get("location") ?? "";
108
+ // After /login the user should land at /account/change-password?next=/admin/permissions.
109
+ // The location header carries that as a percent-encoded `next` param;
110
+ // decode twice (once for the outer `?next=`, once for the inner
111
+ // `?next=` inside the change-password target).
112
+ const outerNext = decodeURIComponent(loc.split("?next=")[1] ?? "");
113
+ expect(outerNext.startsWith("/account/change-password")).toBe(true);
114
+ expect(decodeURIComponent(outerNext)).toContain("/admin/permissions");
115
+ });
116
+
117
+ test("passwordChanged=false renders the first-time heading", async () => {
118
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "default-pw", {
119
+ passwordChanged: false,
120
+ });
121
+ const req = new Request("http://hub.test/account/change-password", {
122
+ headers: { cookie },
123
+ });
124
+ const res = handleAccountChangePasswordGet(req, { db: harness.db });
125
+ expect(res.status).toBe(200);
126
+ const html = await res.text();
127
+ expect(html).toContain("First-time login");
128
+ // form posts back to the same path
129
+ expect(html).toContain('action="/account/change-password"');
130
+ // signed-in-as label includes the username
131
+ expect(html).toContain("newbie");
132
+ });
133
+
134
+ test("passwordChanged=true renders the rotate heading (direct nav is allowed)", async () => {
135
+ // Per design §"Direct navigation": a user with the flag flipped can
136
+ // still navigate here to rotate their password. The redirect at /login
137
+ // is the only flag-gated behavior.
138
+ const { cookie } = await sessionCookieFor(harness.db, "admin", "admin-pw", {
139
+ passwordChanged: true,
140
+ });
141
+ const req = new Request("http://hub.test/account/change-password", {
142
+ headers: { cookie },
143
+ });
144
+ const res = handleAccountChangePasswordGet(req, { db: harness.db });
145
+ expect(res.status).toBe(200);
146
+ const html = await res.text();
147
+ expect(html).toContain("Change your password");
148
+ expect(html).not.toContain("First-time login");
149
+ });
150
+
151
+ test("session pointing at non-existent user → 302 /login (graceful logout)", async () => {
152
+ // Defends against the race where a session row outlives the user it
153
+ // points at (a delete-user via SQL shell, a corrupted restore, etc.).
154
+ // The on-disk FK normally guards against this state, so forge it
155
+ // explicitly with `PRAGMA foreign_keys=OFF` around the INSERT.
156
+ const sessionId = "test-stale-session-id-base64url-padding";
157
+ harness.db.exec("PRAGMA foreign_keys = OFF");
158
+ try {
159
+ harness.db
160
+ .prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
161
+ .run(
162
+ sessionId,
163
+ "nonexistent-user-uuid",
164
+ new Date(Date.now() + 60_000).toISOString(),
165
+ new Date().toISOString(),
166
+ );
167
+ } finally {
168
+ harness.db.exec("PRAGMA foreign_keys = ON");
169
+ }
170
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, Math.floor(SESSION_TTL_MS / 1000))}`;
171
+ const req = new Request("http://hub.test/account/change-password", {
172
+ headers: { cookie },
173
+ });
174
+ const res = handleAccountChangePasswordGet(req, { db: harness.db });
175
+ expect(res.status).toBe(302);
176
+ expect(res.headers.get("location")).toBe("/login");
177
+ });
178
+ });
179
+
180
+ describe("POST /account/change-password", () => {
181
+ test("no session → 401", async () => {
182
+ const { body, headers } = formBody({
183
+ [CSRF_FIELD_NAME]: TEST_CSRF,
184
+ current_password: "default-pw",
185
+ new_password: "long-enough-passphrase",
186
+ new_password_confirm: "long-enough-passphrase",
187
+ });
188
+ const req = new Request("http://hub.test/account/change-password", {
189
+ method: "POST",
190
+ headers: { ...headers, cookie: CSRF_COOKIE },
191
+ body,
192
+ });
193
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
194
+ expect(res.status).toBe(401);
195
+ });
196
+
197
+ test("CSRF mismatch → 400", async () => {
198
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "default-pw");
199
+ const { body, headers } = formBody({
200
+ [CSRF_FIELD_NAME]: "wrong-token",
201
+ current_password: "default-pw",
202
+ new_password: "long-enough-passphrase",
203
+ new_password_confirm: "long-enough-passphrase",
204
+ });
205
+ const req = new Request("http://hub.test/account/change-password", {
206
+ method: "POST",
207
+ headers: { ...headers, cookie },
208
+ body,
209
+ });
210
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
211
+ expect(res.status).toBe(400);
212
+ });
213
+
214
+ test("happy path: hash updates, password_changed flips to 1, 302 to next", async () => {
215
+ const { userId, cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
216
+ passwordChanged: false,
217
+ });
218
+ const before = getUserById(harness.db, userId);
219
+ expect(before?.passwordChanged).toBe(false);
220
+ const oldHash = before?.passwordHash;
221
+ const { body, headers } = formBody({
222
+ [CSRF_FIELD_NAME]: TEST_CSRF,
223
+ current_password: "old-default-pw",
224
+ new_password: "user-chosen-strong-passphrase",
225
+ new_password_confirm: "user-chosen-strong-passphrase",
226
+ next: "/admin/permissions",
227
+ });
228
+ const req = new Request("http://hub.test/account/change-password", {
229
+ method: "POST",
230
+ headers: { ...headers, cookie },
231
+ body,
232
+ });
233
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
234
+ expect(res.status).toBe(302);
235
+ expect(res.headers.get("location")).toBe("/admin/permissions");
236
+
237
+ const after = getUserById(harness.db, userId);
238
+ expect(after?.passwordChanged).toBe(true);
239
+ expect(after?.passwordHash).not.toBe(oldHash);
240
+ expect(after).toBeTruthy();
241
+ if (after) {
242
+ expect(await verifyPassword(after, "user-chosen-strong-passphrase")).toBe(true);
243
+ expect(await verifyPassword(after, "old-default-pw")).toBe(false);
244
+ }
245
+ });
246
+
247
+ test("missing next defaults to /admin/vaults", async () => {
248
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
249
+ passwordChanged: false,
250
+ });
251
+ const { body, headers } = formBody({
252
+ [CSRF_FIELD_NAME]: TEST_CSRF,
253
+ current_password: "old-default-pw",
254
+ new_password: "user-chosen-strong-passphrase",
255
+ new_password_confirm: "user-chosen-strong-passphrase",
256
+ });
257
+ const req = new Request("http://hub.test/account/change-password", {
258
+ method: "POST",
259
+ headers: { ...headers, cookie },
260
+ body,
261
+ });
262
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
263
+ expect(res.status).toBe(302);
264
+ expect(res.headers.get("location")).toBe("/admin/vaults");
265
+ });
266
+
267
+ test("wrong current_password → 401, no state change", async () => {
268
+ const { userId, cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
269
+ passwordChanged: false,
270
+ });
271
+ const oldHash = getUserById(harness.db, userId)?.passwordHash;
272
+ const { body, headers } = formBody({
273
+ [CSRF_FIELD_NAME]: TEST_CSRF,
274
+ current_password: "this-is-not-the-pw",
275
+ new_password: "user-chosen-strong-passphrase",
276
+ new_password_confirm: "user-chosen-strong-passphrase",
277
+ });
278
+ const req = new Request("http://hub.test/account/change-password", {
279
+ method: "POST",
280
+ headers: { ...headers, cookie },
281
+ body,
282
+ });
283
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
284
+ expect(res.status).toBe(401);
285
+ const after = getUserById(harness.db, userId);
286
+ expect(after?.passwordChanged).toBe(false);
287
+ expect(after?.passwordHash).toBe(oldHash ?? "");
288
+ });
289
+
290
+ test("new password too short (< 12) → 400 invalid_password", async () => {
291
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw");
292
+ const { body, headers } = formBody({
293
+ [CSRF_FIELD_NAME]: TEST_CSRF,
294
+ current_password: "old-default-pw",
295
+ new_password: "short",
296
+ new_password_confirm: "short",
297
+ });
298
+ const req = new Request("http://hub.test/account/change-password", {
299
+ method: "POST",
300
+ headers: { ...headers, cookie },
301
+ body,
302
+ });
303
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
304
+ expect(res.status).toBe(400);
305
+ const html = await res.text();
306
+ expect(html).toContain("at least 12");
307
+ });
308
+
309
+ test("new password too long (> PASSWORD_MAX_LEN) → 413, fires before argon2id", async () => {
310
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw");
311
+ const huge = "a".repeat(300);
312
+ const { body, headers } = formBody({
313
+ [CSRF_FIELD_NAME]: TEST_CSRF,
314
+ current_password: "old-default-pw",
315
+ new_password: huge,
316
+ new_password_confirm: huge,
317
+ });
318
+ const req = new Request("http://hub.test/account/change-password", {
319
+ method: "POST",
320
+ headers: { ...headers, cookie },
321
+ body,
322
+ });
323
+ const t0 = Date.now();
324
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
325
+ const elapsed = Date.now() - t0;
326
+ expect(res.status).toBe(413);
327
+ // Same timing-pin pattern PR 2 uses on /api/users — the cap should
328
+ // reject in < 200ms even on a noisy runner; an argon2id verify of the
329
+ // current password would push elapsed into the hundreds of ms.
330
+ expect(elapsed).toBeLessThan(200);
331
+ });
332
+
333
+ test("current_password too long (> PASSWORD_MAX_LEN) → 413, fires before argonVerify (PR-3 fold N1)", async () => {
334
+ // Fold N1: a session-authenticated caller could otherwise submit a
335
+ // huge `current_password` and burn argon2id verify cycles on
336
+ // arbitrary input. The cap should reject before verifyPassword
337
+ // touches the body — pin with elapsed-time floor < 200ms.
338
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
339
+ passwordChanged: false,
340
+ });
341
+ const huge = "x".repeat(5000);
342
+ const { body, headers } = formBody({
343
+ [CSRF_FIELD_NAME]: TEST_CSRF,
344
+ current_password: huge,
345
+ new_password: "user-chosen-strong-passphrase",
346
+ new_password_confirm: "user-chosen-strong-passphrase",
347
+ });
348
+ const req = new Request("http://hub.test/account/change-password", {
349
+ method: "POST",
350
+ headers: { ...headers, cookie },
351
+ body,
352
+ });
353
+ const t0 = Date.now();
354
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
355
+ const elapsed = Date.now() - t0;
356
+ expect(res.status).toBe(413);
357
+ const html = await res.text();
358
+ expect(html).toContain("Current password must be");
359
+ // Pin: cap-and-reject < 200ms even on a noisy runner. An argon2id
360
+ // verify of the (correct length but mistyped) current password would
361
+ // push elapsed into the hundreds of ms.
362
+ expect(elapsed).toBeLessThan(200);
363
+ });
364
+
365
+ test("new !== confirm → 400 password_mismatch", async () => {
366
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw");
367
+ const { body, headers } = formBody({
368
+ [CSRF_FIELD_NAME]: TEST_CSRF,
369
+ current_password: "old-default-pw",
370
+ new_password: "user-chosen-strong-passphrase",
371
+ new_password_confirm: "user-chosen-strong-passphraze", // typo
372
+ });
373
+ const req = new Request("http://hub.test/account/change-password", {
374
+ method: "POST",
375
+ headers: { ...headers, cookie },
376
+ body,
377
+ });
378
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
379
+ expect(res.status).toBe(400);
380
+ const html = await res.text();
381
+ expect(html).toContain("do not match");
382
+ });
383
+
384
+ test("new === current → 400 password_unchanged (after verify-current passes)", async () => {
385
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "same-old-password");
386
+ const { body, headers } = formBody({
387
+ [CSRF_FIELD_NAME]: TEST_CSRF,
388
+ current_password: "same-old-password",
389
+ new_password: "same-old-password",
390
+ new_password_confirm: "same-old-password",
391
+ });
392
+ const req = new Request("http://hub.test/account/change-password", {
393
+ method: "POST",
394
+ headers: { ...headers, cookie },
395
+ body,
396
+ });
397
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
398
+ expect(res.status).toBe(400);
399
+ const html = await res.text();
400
+ expect(html).toContain("differ from your current");
401
+ });
402
+
403
+ test("missing required field → 400", async () => {
404
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw");
405
+ const { body, headers } = formBody({
406
+ [CSRF_FIELD_NAME]: TEST_CSRF,
407
+ current_password: "old-default-pw",
408
+ // new_password omitted
409
+ new_password_confirm: "long-enough-passphrase",
410
+ });
411
+ const req = new Request("http://hub.test/account/change-password", {
412
+ method: "POST",
413
+ headers: { ...headers, cookie },
414
+ body,
415
+ });
416
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
417
+ expect(res.status).toBe(400);
418
+ });
419
+
420
+ test("re-render after failure keeps the user signed in (no session-clear)", async () => {
421
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw");
422
+ const { body, headers } = formBody({
423
+ [CSRF_FIELD_NAME]: TEST_CSRF,
424
+ current_password: "wrong",
425
+ new_password: "long-enough-passphrase",
426
+ new_password_confirm: "long-enough-passphrase",
427
+ });
428
+ const req = new Request("http://hub.test/account/change-password", {
429
+ method: "POST",
430
+ headers: { ...headers, cookie },
431
+ body,
432
+ });
433
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
434
+ expect(res.status).toBe(401);
435
+ // The error re-renders the page; the failure must not clear the
436
+ // session cookie (the user is still signed in, just typed the wrong
437
+ // current password).
438
+ const setCookie = res.headers.get("set-cookie") ?? "";
439
+ expect(setCookie).not.toContain("Max-Age=0");
440
+ });
441
+ });
442
+
443
+ describe("markPasswordChanged", () => {
444
+ test("flips password_changed from 0 to 1 and bumps updated_at", async () => {
445
+ const user = await createUser(harness.db, "newbie", "pw", { passwordChanged: false });
446
+ const before = getUserById(harness.db, user.id);
447
+ expect(before?.passwordChanged).toBe(false);
448
+ const beforeStamp = before?.updatedAt ?? "";
449
+ // Pin the clock so the assertion is deterministic.
450
+ const fixed = new Date(Date.parse(beforeStamp) + 1000);
451
+ markPasswordChanged(harness.db, user.id, () => fixed);
452
+ const after = getUserById(harness.db, user.id);
453
+ expect(after?.passwordChanged).toBe(true);
454
+ expect(after?.updatedAt).toBe(fixed.toISOString());
455
+ });
456
+
457
+ test("idempotent — running against an already-true row stays true", async () => {
458
+ const user = await createUser(harness.db, "admin", "pw", { passwordChanged: true });
459
+ markPasswordChanged(harness.db, user.id);
460
+ const after = getUserById(harness.db, user.id);
461
+ expect(after?.passwordChanged).toBe(true);
462
+ });
463
+ });