@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.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +74 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +216 -3
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +15 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +134 -16
- package/src/users.ts +210 -3
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/package.json
CHANGED
|
@@ -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
|
+
});
|