@openparachute/hub 0.5.13 → 0.5.14-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for `/api/users*` (multi-user Phase 1
|
|
2
|
+
* Tests for `/api/users*` (multi-user Phase 1 PR 2 + Phase 2 PR 1).
|
|
3
|
+
* Covers:
|
|
3
4
|
*
|
|
4
5
|
* - Auth boundary: every endpoint requires a bearer carrying
|
|
5
6
|
* `parachute:host:admin`.
|
|
@@ -11,6 +12,10 @@
|
|
|
11
12
|
* 400 `assigned_vault_not_found`).
|
|
12
13
|
* - DELETE happy path with token revocation; first-admin-undeletable
|
|
13
14
|
* returns 403; 404 on unknown id.
|
|
15
|
+
* - POST /:id/reset-password (Phase 2 PR 1) happy path with token
|
|
16
|
+
* revocation, first-admin protection (403 cannot_reset_first_admin),
|
|
17
|
+
* password-validation branches (too-short 400, too-long 413 before
|
|
18
|
+
* argon2id), missing target (404), auth boundary.
|
|
14
19
|
* - GET /api/users/vaults returns the same name set the OAuth issuer
|
|
15
20
|
* would resolve against.
|
|
16
21
|
* - 405 on wrong methods.
|
|
@@ -25,10 +30,12 @@ import {
|
|
|
25
30
|
handleDeleteUser,
|
|
26
31
|
handleListUsers,
|
|
27
32
|
handleListVaults,
|
|
33
|
+
handleResetUserPassword,
|
|
34
|
+
handleUpdateUserVaults,
|
|
28
35
|
} from "../api-users.ts";
|
|
29
36
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
30
37
|
import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
31
|
-
import { createUser } from "../users.ts";
|
|
38
|
+
import { createUser, getUserById, verifyPassword } from "../users.ts";
|
|
32
39
|
|
|
33
40
|
const ISSUER = "https://hub.test";
|
|
34
41
|
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
@@ -170,7 +177,7 @@ describe("handleListUsers", () => {
|
|
|
170
177
|
expect(u).toHaveProperty("id");
|
|
171
178
|
expect(u).toHaveProperty("username");
|
|
172
179
|
expect(u).toHaveProperty("password_changed");
|
|
173
|
-
expect(u).toHaveProperty("
|
|
180
|
+
expect(u).toHaveProperty("assigned_vaults");
|
|
174
181
|
expect(u).toHaveProperty("created_at");
|
|
175
182
|
}
|
|
176
183
|
});
|
|
@@ -214,13 +221,13 @@ describe("handleCreateUser", () => {
|
|
|
214
221
|
const res = await post(bearer, {
|
|
215
222
|
username: "alice",
|
|
216
223
|
password: "alice-strong-passphrase",
|
|
217
|
-
|
|
224
|
+
assignedVaults: [],
|
|
218
225
|
});
|
|
219
226
|
expect(res.status).toBe(201);
|
|
220
227
|
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
221
228
|
expect(body.user.username).toBe("alice");
|
|
222
229
|
expect(body.user.password_changed).toBe(false);
|
|
223
|
-
expect(body.user.
|
|
230
|
+
expect(body.user.assigned_vaults).toEqual([]);
|
|
224
231
|
expect(body.user).not.toHaveProperty("password_hash");
|
|
225
232
|
});
|
|
226
233
|
|
|
@@ -325,9 +332,9 @@ describe("handleCreateUser", () => {
|
|
|
325
332
|
const stamp = "2026-05-20T00:00:00.000Z";
|
|
326
333
|
harness.db
|
|
327
334
|
.prepare(
|
|
328
|
-
"INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed
|
|
335
|
+
"INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, ?)",
|
|
329
336
|
)
|
|
330
|
-
.run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1
|
|
337
|
+
.run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1);
|
|
331
338
|
const { bearer } = await makeAdminBearer();
|
|
332
339
|
const res = await post(bearer, {
|
|
333
340
|
username: "alice",
|
|
@@ -343,25 +350,55 @@ describe("handleCreateUser", () => {
|
|
|
343
350
|
const res = await post(bearer, {
|
|
344
351
|
username: "alice",
|
|
345
352
|
password: "alice-strong-passphrase",
|
|
346
|
-
|
|
353
|
+
assignedVaults: ["ghost-vault"],
|
|
347
354
|
});
|
|
348
355
|
expect(res.status).toBe(400);
|
|
349
356
|
const body = (await res.json()) as { error: string };
|
|
350
357
|
expect(body.error).toBe("assigned_vault_not_found");
|
|
351
358
|
});
|
|
352
359
|
|
|
353
|
-
test("happy path with
|
|
360
|
+
test("happy path with single assigned_vaults that exists in services.json", async () => {
|
|
354
361
|
harness.cleanup();
|
|
355
362
|
harness = makeHarness(manifestWithVaults("home"));
|
|
356
363
|
const { bearer } = await makeAdminBearer();
|
|
357
364
|
const res = await post(bearer, {
|
|
358
365
|
username: "alice",
|
|
359
366
|
password: "alice-strong-passphrase",
|
|
360
|
-
|
|
367
|
+
assignedVaults: ["home"],
|
|
361
368
|
});
|
|
362
369
|
expect(res.status).toBe(201);
|
|
363
370
|
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
364
|
-
expect(body.user.
|
|
371
|
+
expect(body.user.assigned_vaults).toEqual(["home"]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("happy path with multiple assigned_vaults (Phase 2 PR 2)", async () => {
|
|
375
|
+
harness.cleanup();
|
|
376
|
+
harness = makeHarness(manifestWithVaults("personal", "family"));
|
|
377
|
+
const { bearer } = await makeAdminBearer();
|
|
378
|
+
const res = await post(bearer, {
|
|
379
|
+
username: "alice",
|
|
380
|
+
password: "alice-strong-passphrase",
|
|
381
|
+
assignedVaults: ["personal", "family"],
|
|
382
|
+
});
|
|
383
|
+
expect(res.status).toBe(201);
|
|
384
|
+
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
385
|
+
const list = body.user.assigned_vaults as string[];
|
|
386
|
+
expect(new Set(list)).toEqual(new Set(["personal", "family"]));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("400 assigned_vault_not_found when ANY vault in the array is unknown", async () => {
|
|
390
|
+
harness.cleanup();
|
|
391
|
+
harness = makeHarness(manifestWithVaults("home"));
|
|
392
|
+
const { bearer } = await makeAdminBearer();
|
|
393
|
+
const res = await post(bearer, {
|
|
394
|
+
username: "alice",
|
|
395
|
+
password: "alice-strong-passphrase",
|
|
396
|
+
assignedVaults: ["home", "ghost"],
|
|
397
|
+
});
|
|
398
|
+
expect(res.status).toBe(400);
|
|
399
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
400
|
+
expect(body.error).toBe("assigned_vault_not_found");
|
|
401
|
+
expect(body.error_description).toContain("ghost");
|
|
365
402
|
});
|
|
366
403
|
});
|
|
367
404
|
|
|
@@ -477,6 +514,214 @@ describe("handleDeleteUser", () => {
|
|
|
477
514
|
});
|
|
478
515
|
});
|
|
479
516
|
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// POST /api/users/:id/reset-password — admin-initiated password reset
|
|
519
|
+
// (multi-user Phase 2 PR 1)
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
describe("handleResetUserPassword", () => {
|
|
523
|
+
async function post(
|
|
524
|
+
bearer: string | null,
|
|
525
|
+
id: string,
|
|
526
|
+
body: Record<string, unknown> | string | null,
|
|
527
|
+
headers: Record<string, string> = {},
|
|
528
|
+
): Promise<Response> {
|
|
529
|
+
const init: RequestInit = {
|
|
530
|
+
method: "POST",
|
|
531
|
+
headers: {
|
|
532
|
+
"content-type": "application/json",
|
|
533
|
+
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
|
|
534
|
+
...headers,
|
|
535
|
+
},
|
|
536
|
+
body: body === null ? undefined : typeof body === "string" ? body : JSON.stringify(body),
|
|
537
|
+
};
|
|
538
|
+
return await handleResetUserPassword(req(`/api/users/${id}/reset-password`, init), id, deps());
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
test("401 with no Authorization header", async () => {
|
|
542
|
+
const res = await post(null, "some-id", { new_password: "twelvechars1" });
|
|
543
|
+
expect(res.status).toBe(401);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
547
|
+
const { bearer } = await makeAdminBearer(["other:scope"]);
|
|
548
|
+
const res = await post(bearer, "some-id", { new_password: "twelvechars1" });
|
|
549
|
+
expect(res.status).toBe(403);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("405 on GET", async () => {
|
|
553
|
+
const { bearer } = await makeAdminBearer();
|
|
554
|
+
const res = await handleResetUserPassword(
|
|
555
|
+
withBearer("/api/users/some-id/reset-password", bearer, { method: "GET" }),
|
|
556
|
+
"some-id",
|
|
557
|
+
deps(),
|
|
558
|
+
);
|
|
559
|
+
expect(res.status).toBe(405);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("404 when target user does not exist", async () => {
|
|
563
|
+
const { bearer } = await makeAdminBearer();
|
|
564
|
+
const res = await post(bearer, "no-such-id", { new_password: "twelvechars1" });
|
|
565
|
+
expect(res.status).toBe(404);
|
|
566
|
+
const body = (await res.json()) as { error: string };
|
|
567
|
+
expect(body.error).toBe("not_found");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("403 cannot_reset_first_admin when targeting the first admin", async () => {
|
|
571
|
+
const { bearer, userId } = await makeAdminBearer();
|
|
572
|
+
const res = await post(bearer, userId, { new_password: "twelvechars1" });
|
|
573
|
+
expect(res.status).toBe(403);
|
|
574
|
+
const body = (await res.json()) as { error: string };
|
|
575
|
+
expect(body.error).toBe("cannot_reset_first_admin");
|
|
576
|
+
// First-admin row's hash + password_changed bit untouched.
|
|
577
|
+
const fresh = getUserById(harness.db, userId);
|
|
578
|
+
expect(fresh?.passwordChanged).toBe(true);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("400 invalid_password when new_password is too short (< 12 chars)", async () => {
|
|
582
|
+
const { bearer } = await makeAdminBearer();
|
|
583
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
584
|
+
allowMulti: true,
|
|
585
|
+
passwordChanged: true,
|
|
586
|
+
});
|
|
587
|
+
const res = await post(bearer, friend.id, { new_password: "short" });
|
|
588
|
+
expect(res.status).toBe(400);
|
|
589
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
590
|
+
expect(body.error).toBe("invalid_password");
|
|
591
|
+
expect(body.error_description).toMatch(/12 characters/);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("413 password_too_long when new_password > 256 chars (before argon2id touches it)", async () => {
|
|
595
|
+
const { bearer } = await makeAdminBearer();
|
|
596
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
597
|
+
allowMulti: true,
|
|
598
|
+
passwordChanged: true,
|
|
599
|
+
});
|
|
600
|
+
const huge = "a".repeat(300);
|
|
601
|
+
const t0 = Date.now();
|
|
602
|
+
const res = await post(bearer, friend.id, { new_password: huge });
|
|
603
|
+
const elapsed = Date.now() - t0;
|
|
604
|
+
expect(res.status).toBe(413);
|
|
605
|
+
const body = (await res.json()) as { error: string };
|
|
606
|
+
expect(body.error).toBe("password_too_long");
|
|
607
|
+
// Same liveness check as the create-user path: 300-char argon2id is
|
|
608
|
+
// hundreds of ms; cap-and-reject should complete in <200ms.
|
|
609
|
+
expect(elapsed).toBeLessThan(200);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("400 invalid_request when content-type is not application/json", async () => {
|
|
613
|
+
const { bearer } = await makeAdminBearer();
|
|
614
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
615
|
+
allowMulti: true,
|
|
616
|
+
passwordChanged: true,
|
|
617
|
+
});
|
|
618
|
+
const res = await post(bearer, friend.id, "new_password=twelvechars1", {
|
|
619
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
620
|
+
});
|
|
621
|
+
expect(res.status).toBe(400);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("400 invalid_request when new_password missing", async () => {
|
|
625
|
+
const { bearer } = await makeAdminBearer();
|
|
626
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
627
|
+
allowMulti: true,
|
|
628
|
+
passwordChanged: true,
|
|
629
|
+
});
|
|
630
|
+
const res = await post(bearer, friend.id, {});
|
|
631
|
+
expect(res.status).toBe(400);
|
|
632
|
+
const body = (await res.json()) as { error: string };
|
|
633
|
+
expect(body.error).toBe("invalid_request");
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("happy path — rotates hash, flips password_changed=false, returns user shape", async () => {
|
|
637
|
+
const { bearer } = await makeAdminBearer();
|
|
638
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
639
|
+
allowMulti: true,
|
|
640
|
+
passwordChanged: true,
|
|
641
|
+
});
|
|
642
|
+
const oldHash = friend.passwordHash;
|
|
643
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
644
|
+
expect(res.status).toBe(200);
|
|
645
|
+
const body = (await res.json()) as {
|
|
646
|
+
ok: boolean;
|
|
647
|
+
user: { id: string; password_changed: boolean; username: string };
|
|
648
|
+
};
|
|
649
|
+
expect(body.ok).toBe(true);
|
|
650
|
+
expect(body.user.id).toBe(friend.id);
|
|
651
|
+
expect(body.user.username).toBe("alice");
|
|
652
|
+
expect(body.user.password_changed).toBe(false);
|
|
653
|
+
// Echo body never carries the hash (or the new password).
|
|
654
|
+
expect(body.user).not.toHaveProperty("password_hash");
|
|
655
|
+
expect(body).not.toHaveProperty("new_password");
|
|
656
|
+
// Round-trip on the user row: new password works, old does not, hash
|
|
657
|
+
// moved, password_changed is now false (force-redirect on next login).
|
|
658
|
+
const fresh = getUserById(harness.db, friend.id);
|
|
659
|
+
expect(fresh).not.toBeNull();
|
|
660
|
+
expect(fresh?.passwordHash).not.toBe(oldHash);
|
|
661
|
+
expect(fresh?.passwordChanged).toBe(false);
|
|
662
|
+
expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
|
|
663
|
+
expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("response body includes revocation_lag_seconds = 60 so clients can surface the propagation lag (smoke 2026-05-27 finding 3)", async () => {
|
|
667
|
+
// Resource servers cache the revocation list via scope-guard's
|
|
668
|
+
// REVOCATION_CACHE_TTL_MS = 60_000 — so even though hub marks the
|
|
669
|
+
// user's tokens revoked immediately and publishes them on the
|
|
670
|
+
// revocation list, vault/scribe/etc. may keep accepting the
|
|
671
|
+
// revoked token for up to 60 seconds. For the stolen-device
|
|
672
|
+
// recovery threat model that's a meaningful exposure window
|
|
673
|
+
// the admin needs to know about. Surface the lag in the
|
|
674
|
+
// response body so the SPA's success banner can warn operators.
|
|
675
|
+
const { bearer } = await makeAdminBearer();
|
|
676
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
677
|
+
allowMulti: true,
|
|
678
|
+
passwordChanged: true,
|
|
679
|
+
});
|
|
680
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
681
|
+
expect(res.status).toBe(200);
|
|
682
|
+
const body = (await res.json()) as {
|
|
683
|
+
ok: boolean;
|
|
684
|
+
user: unknown;
|
|
685
|
+
revocation_lag_seconds: number;
|
|
686
|
+
};
|
|
687
|
+
expect(body.revocation_lag_seconds).toBe(60);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
|
|
691
|
+
const { bearer } = await makeAdminBearer();
|
|
692
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
693
|
+
allowMulti: true,
|
|
694
|
+
passwordChanged: true,
|
|
695
|
+
});
|
|
696
|
+
const minted = await signAccessToken(harness.db, {
|
|
697
|
+
sub: friend.id,
|
|
698
|
+
scopes: ["vault:home:read"],
|
|
699
|
+
audience: "vault",
|
|
700
|
+
clientId: "notes-client",
|
|
701
|
+
issuer: ISSUER,
|
|
702
|
+
ttlSeconds: 600,
|
|
703
|
+
});
|
|
704
|
+
recordTokenMint(harness.db, {
|
|
705
|
+
jti: minted.jti,
|
|
706
|
+
createdVia: "operator_mint",
|
|
707
|
+
subject: friend.username,
|
|
708
|
+
userId: friend.id,
|
|
709
|
+
clientId: "notes-client",
|
|
710
|
+
scopes: ["vault:home:read"],
|
|
711
|
+
expiresAt: minted.expiresAt,
|
|
712
|
+
});
|
|
713
|
+
expect(findTokenRowByJti(harness.db, minted.jti)?.revokedAt).toBeNull();
|
|
714
|
+
|
|
715
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
716
|
+
expect(res.status).toBe(200);
|
|
717
|
+
|
|
718
|
+
const row = findTokenRowByJti(harness.db, minted.jti);
|
|
719
|
+
expect(row?.revokedAt).not.toBeNull();
|
|
720
|
+
// User row sticks around (unlike delete), so user_id is NOT NULLed.
|
|
721
|
+
expect(row?.userId).toBe(friend.id);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
480
725
|
// ---------------------------------------------------------------------------
|
|
481
726
|
// GET /api/users/vaults — vault-name list for the assigned-vault dropdown
|
|
482
727
|
// ---------------------------------------------------------------------------
|
|
@@ -520,3 +765,130 @@ describe("handleListVaults", () => {
|
|
|
520
765
|
expect(body.vaults).toEqual(["home", "scratch", "work"]);
|
|
521
766
|
});
|
|
522
767
|
});
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
// PATCH /api/users/:id/vaults — edit vault assignments (Phase 2 PR 2)
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
describe("handleUpdateUserVaults", () => {
|
|
774
|
+
async function patch(
|
|
775
|
+
bearer: string,
|
|
776
|
+
userId: string,
|
|
777
|
+
body: Record<string, unknown> | string,
|
|
778
|
+
headers: Record<string, string> = {},
|
|
779
|
+
): Promise<Response> {
|
|
780
|
+
const init: RequestInit = {
|
|
781
|
+
method: "PATCH",
|
|
782
|
+
headers: {
|
|
783
|
+
"content-type": "application/json",
|
|
784
|
+
authorization: `Bearer ${bearer}`,
|
|
785
|
+
...headers,
|
|
786
|
+
},
|
|
787
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
788
|
+
};
|
|
789
|
+
return await handleUpdateUserVaults(req(`/api/users/${userId}/vaults`, init), userId, deps());
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
test("401 with no Authorization header", async () => {
|
|
793
|
+
const res = await handleUpdateUserVaults(
|
|
794
|
+
req("/api/users/some-id/vaults", {
|
|
795
|
+
method: "PATCH",
|
|
796
|
+
headers: { "content-type": "application/json" },
|
|
797
|
+
body: JSON.stringify({ assigned_vaults: [] }),
|
|
798
|
+
}),
|
|
799
|
+
"some-id",
|
|
800
|
+
deps(),
|
|
801
|
+
);
|
|
802
|
+
expect(res.status).toBe(401);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("405 on non-PATCH", async () => {
|
|
806
|
+
const { bearer } = await makeAdminBearer();
|
|
807
|
+
const res = await handleUpdateUserVaults(
|
|
808
|
+
withBearer("/api/users/x/vaults", bearer, { method: "POST" }),
|
|
809
|
+
"x",
|
|
810
|
+
deps(),
|
|
811
|
+
);
|
|
812
|
+
expect(res.status).toBe(405);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("404 when target user does not exist", async () => {
|
|
816
|
+
const { bearer } = await makeAdminBearer();
|
|
817
|
+
const res = await patch(bearer, "no-such-id", { assigned_vaults: [] });
|
|
818
|
+
expect(res.status).toBe(404);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("403 cannot_edit_first_admin_vaults for the first admin", async () => {
|
|
822
|
+
// The bearer-minting helper creates the first admin already; mint
|
|
823
|
+
// again returns the same admin's id.
|
|
824
|
+
const { bearer, userId } = await makeAdminBearer();
|
|
825
|
+
const res = await patch(bearer, userId, { assigned_vaults: ["home"] });
|
|
826
|
+
expect(res.status).toBe(403);
|
|
827
|
+
const body = (await res.json()) as { error: string };
|
|
828
|
+
expect(body.error).toBe("cannot_edit_first_admin_vaults");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("400 when assigned_vaults is missing", async () => {
|
|
832
|
+
const { bearer } = await makeAdminBearer();
|
|
833
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
834
|
+
allowMulti: true,
|
|
835
|
+
});
|
|
836
|
+
const res = await patch(bearer, friend.id, {});
|
|
837
|
+
expect(res.status).toBe(400);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("400 when assigned_vaults entry is not a string", async () => {
|
|
841
|
+
const { bearer } = await makeAdminBearer();
|
|
842
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
843
|
+
allowMulti: true,
|
|
844
|
+
});
|
|
845
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: ["ok", 7] });
|
|
846
|
+
expect(res.status).toBe(400);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("400 assigned_vault_not_found when a vault doesn't exist in services.json", async () => {
|
|
850
|
+
harness.cleanup();
|
|
851
|
+
harness = makeHarness(manifestWithVaults("home"));
|
|
852
|
+
const { bearer } = await makeAdminBearer();
|
|
853
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
854
|
+
allowMulti: true,
|
|
855
|
+
});
|
|
856
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: ["home", "ghost"] });
|
|
857
|
+
expect(res.status).toBe(400);
|
|
858
|
+
const body = (await res.json()) as { error: string };
|
|
859
|
+
expect(body.error).toBe("assigned_vault_not_found");
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test("happy path — replaces the user's vault list and bumps updated_at", async () => {
|
|
863
|
+
harness.cleanup();
|
|
864
|
+
harness = makeHarness(manifestWithVaults("home", "work", "family"));
|
|
865
|
+
const { bearer } = await makeAdminBearer();
|
|
866
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
867
|
+
allowMulti: true,
|
|
868
|
+
assignedVaults: ["home"],
|
|
869
|
+
});
|
|
870
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: ["work", "family"] });
|
|
871
|
+
expect(res.status).toBe(200);
|
|
872
|
+
const body = (await res.json()) as {
|
|
873
|
+
ok: boolean;
|
|
874
|
+
user: { id: string; assigned_vaults: string[]; updated_at: string };
|
|
875
|
+
};
|
|
876
|
+
expect(body.ok).toBe(true);
|
|
877
|
+
expect(new Set(body.user.assigned_vaults)).toEqual(new Set(["work", "family"]));
|
|
878
|
+
expect(body.user.assigned_vaults).not.toContain("home");
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("empty array clears every assignment", async () => {
|
|
882
|
+
harness.cleanup();
|
|
883
|
+
harness = makeHarness(manifestWithVaults("home", "work"));
|
|
884
|
+
const { bearer } = await makeAdminBearer();
|
|
885
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
886
|
+
allowMulti: true,
|
|
887
|
+
assignedVaults: ["home", "work"],
|
|
888
|
+
});
|
|
889
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: [] });
|
|
890
|
+
expect(res.status).toBe(200);
|
|
891
|
+
const fresh = getUserById(harness.db, friend.id);
|
|
892
|
+
expect(fresh?.assignedVaults).toEqual([]);
|
|
893
|
+
});
|
|
894
|
+
});
|
|
@@ -82,21 +82,21 @@ describe("shouldInjectChrome", () => {
|
|
|
82
82
|
expect(shouldInjectChrome("/admin/vaults")).toBe(true);
|
|
83
83
|
expect(shouldInjectChrome("/scribe/admin")).toBe(true);
|
|
84
84
|
expect(shouldInjectChrome("/vault/default/admin/")).toBe(true);
|
|
85
|
-
expect(shouldInjectChrome("/
|
|
85
|
+
expect(shouldInjectChrome("/surface/admin/modules")).toBe(true);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
test("default: opts out the Notes PWA at /
|
|
89
|
-
expect(shouldInjectChrome("/
|
|
90
|
-
expect(shouldInjectChrome("/
|
|
91
|
-
expect(shouldInjectChrome("/
|
|
92
|
-
expect(shouldInjectChrome("/
|
|
88
|
+
test("default: opts out the Notes PWA at /surface/notes/*", () => {
|
|
89
|
+
expect(shouldInjectChrome("/surface/notes")).toBe(false);
|
|
90
|
+
expect(shouldInjectChrome("/surface/notes/")).toBe(false);
|
|
91
|
+
expect(shouldInjectChrome("/surface/notes/index.html")).toBe(false);
|
|
92
|
+
expect(shouldInjectChrome("/surface/notes/assets/index-XXX.js")).toBe(false);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
test("opt-out prefix matching does not over-match sibling paths", () => {
|
|
96
|
-
// `/
|
|
96
|
+
// `/surface/notesbook` must NOT match `/surface/notes/` — startsWith check
|
|
97
97
|
// requires a trailing slash boundary.
|
|
98
|
-
expect(shouldInjectChrome("/
|
|
99
|
-
expect(shouldInjectChrome("/
|
|
98
|
+
expect(shouldInjectChrome("/surface/notesbook")).toBe(true);
|
|
99
|
+
expect(shouldInjectChrome("/surface/notes-archive/")).toBe(true);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
test("custom opt-out list is honored", () => {
|
|
@@ -111,8 +111,8 @@ describe("shouldInjectChrome", () => {
|
|
|
111
111
|
expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test("the canonical opt-out list contains /
|
|
115
|
-
expect(CHROME_OPT_OUT_PREFIXES).toContain("/
|
|
114
|
+
test("the canonical opt-out list contains /surface/notes/", () => {
|
|
115
|
+
expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/notes/");
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|
|
@@ -250,27 +250,27 @@ describe("injectChromeIntoResponse", () => {
|
|
|
250
250
|
expect(cssOut).toBe(css);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
-
test("passes through responses on opt-out paths (/
|
|
253
|
+
test("passes through responses on opt-out paths (/surface/notes/) unchanged", async () => {
|
|
254
254
|
const res = new Response("<html><body>notes</body></html>", {
|
|
255
255
|
status: 200,
|
|
256
256
|
headers: { "content-type": "text/html" },
|
|
257
257
|
});
|
|
258
258
|
const out = await injectChromeIntoResponse(res, {
|
|
259
259
|
chromeHtml: chrome,
|
|
260
|
-
pathname: "/
|
|
260
|
+
pathname: "/surface/notes/",
|
|
261
261
|
});
|
|
262
262
|
expect(out).toBe(res);
|
|
263
263
|
expect(await out.text()).toBe("<html><body>notes</body></html>");
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
test("passes through responses on opt-out sub-paths (/
|
|
266
|
+
test("passes through responses on opt-out sub-paths (/surface/notes/assets/x.js)", async () => {
|
|
267
267
|
const res = new Response("<html><body>notes</body></html>", {
|
|
268
268
|
status: 200,
|
|
269
269
|
headers: { "content-type": "text/html" },
|
|
270
270
|
});
|
|
271
271
|
const out = await injectChromeIntoResponse(res, {
|
|
272
272
|
chromeHtml: chrome,
|
|
273
|
-
pathname: "/
|
|
273
|
+
pathname: "/surface/notes/index.html",
|
|
274
274
|
});
|
|
275
275
|
expect(out).toBe(res);
|
|
276
276
|
});
|