@openparachute/hub 0.5.14-rc.1 → 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 +1 -1
- package/src/__tests__/account-home-ui.test.ts +29 -6
- package/src/__tests__/admin-vault-admin-token.test.ts +2 -2
- package/src/__tests__/api-account.test.ts +2 -2
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-users.test.ts +191 -9
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/setup-wizard.test.ts +336 -6
- package/src/__tests__/users.test.ts +135 -9
- package/src/account-home-ui.ts +57 -27
- package/src/api-account.ts +1 -1
- package/src/api-modules-ops.ts +52 -16
- package/src/api-users.ts +303 -51
- package/src/bun-link.ts +55 -0
- package/src/commands/install.ts +8 -21
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +19 -0
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/setup-wizard.ts +195 -55
- package/src/users.ts +195 -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-Qf56GsGm.js +0 -61
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ describe("renderAccountHome", () => {
|
|
|
22
22
|
test("assigned-vault branch — Notes CTA carries the encoded hub+vault URL", () => {
|
|
23
23
|
const html = renderAccountHome({
|
|
24
24
|
username: "alice",
|
|
25
|
-
|
|
25
|
+
assignedVaults: ["alice"],
|
|
26
26
|
passwordChanged: true,
|
|
27
27
|
hubOrigin: HUB_ORIGIN,
|
|
28
28
|
isFirstAdmin: false,
|
|
@@ -49,7 +49,7 @@ describe("renderAccountHome", () => {
|
|
|
49
49
|
// renderer must produce a clean `/vault/<name>` join either way.
|
|
50
50
|
const html = renderAccountHome({
|
|
51
51
|
username: "alice",
|
|
52
|
-
|
|
52
|
+
assignedVaults: ["alice"],
|
|
53
53
|
passwordChanged: true,
|
|
54
54
|
hubOrigin: `${HUB_ORIGIN}/`,
|
|
55
55
|
isFirstAdmin: false,
|
|
@@ -65,7 +65,7 @@ describe("renderAccountHome", () => {
|
|
|
65
65
|
test("admin branch — null assignedVault + isFirstAdmin renders an /admin/ link", () => {
|
|
66
66
|
const html = renderAccountHome({
|
|
67
67
|
username: "admin",
|
|
68
|
-
|
|
68
|
+
assignedVaults: [],
|
|
69
69
|
passwordChanged: true,
|
|
70
70
|
hubOrigin: HUB_ORIGIN,
|
|
71
71
|
isFirstAdmin: true,
|
|
@@ -85,7 +85,7 @@ describe("renderAccountHome", () => {
|
|
|
85
85
|
// state via hand-edit or migration race.
|
|
86
86
|
const html = renderAccountHome({
|
|
87
87
|
username: "ghost",
|
|
88
|
-
|
|
88
|
+
assignedVaults: [],
|
|
89
89
|
passwordChanged: true,
|
|
90
90
|
hubOrigin: HUB_ORIGIN,
|
|
91
91
|
isFirstAdmin: false,
|
|
@@ -102,7 +102,7 @@ describe("renderAccountHome", () => {
|
|
|
102
102
|
test("account card — change-password link and sign-out form are present", () => {
|
|
103
103
|
const html = renderAccountHome({
|
|
104
104
|
username: "alice",
|
|
105
|
-
|
|
105
|
+
assignedVaults: ["alice"],
|
|
106
106
|
passwordChanged: true,
|
|
107
107
|
hubOrigin: HUB_ORIGIN,
|
|
108
108
|
isFirstAdmin: false,
|
|
@@ -120,6 +120,29 @@ describe("renderAccountHome", () => {
|
|
|
120
120
|
expect(html).toContain("<code>alice</code>");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
|
|
124
|
+
const html = renderAccountHome({
|
|
125
|
+
username: "alice",
|
|
126
|
+
assignedVaults: ["personal", "family"],
|
|
127
|
+
passwordChanged: true,
|
|
128
|
+
hubOrigin: HUB_ORIGIN,
|
|
129
|
+
isFirstAdmin: false,
|
|
130
|
+
csrfToken: CSRF,
|
|
131
|
+
});
|
|
132
|
+
// Plural heading.
|
|
133
|
+
expect(html).toContain("Your vaults");
|
|
134
|
+
// Each vault name appears.
|
|
135
|
+
expect(html).toContain("<strong>personal</strong>");
|
|
136
|
+
expect(html).toContain("<strong>family</strong>");
|
|
137
|
+
// One CTA per vault with the right encoded URL.
|
|
138
|
+
const personalEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/personal`);
|
|
139
|
+
const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
|
|
140
|
+
expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
|
|
141
|
+
expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
|
|
142
|
+
// Hub origin block appears once at the section level, not per tile.
|
|
143
|
+
expect(html.split(`<code>${HUB_ORIGIN}</code>`).length - 1).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
123
146
|
test("escapes hostile content in username and vault name", () => {
|
|
124
147
|
// Defense-in-depth: usernames pass validateUsername (lowercase alnum
|
|
125
148
|
// + `_-`), so HTML metacharacters won't normally make it through. But
|
|
@@ -127,7 +150,7 @@ describe("renderAccountHome", () => {
|
|
|
127
150
|
// escape is load-bearing if the validator ever loosens.
|
|
128
151
|
const html = renderAccountHome({
|
|
129
152
|
username: "<script>",
|
|
130
|
-
|
|
153
|
+
assignedVaults: ["<vault>"],
|
|
131
154
|
passwordChanged: true,
|
|
132
155
|
hubOrigin: HUB_ORIGIN,
|
|
133
156
|
isFirstAdmin: false,
|
|
@@ -160,7 +160,7 @@ describe("handleVaultAdminToken", () => {
|
|
|
160
160
|
// whole reason this gate exists.
|
|
161
161
|
await createUser(harness.db, "operator", "hunter2-admin");
|
|
162
162
|
const friend = await createUser(harness.db, "friend", "hunter2-friend", {
|
|
163
|
-
|
|
163
|
+
assignedVaults: ["work"],
|
|
164
164
|
allowMulti: true,
|
|
165
165
|
});
|
|
166
166
|
const session = createSession(harness.db, { userId: friend.id });
|
|
@@ -182,7 +182,7 @@ describe("handleVaultAdminToken", () => {
|
|
|
182
182
|
// happy path when there are friends in the DB.
|
|
183
183
|
const admin = await createUser(harness.db, "operator", "hunter2-admin");
|
|
184
184
|
await createUser(harness.db, "friend", "hunter2-friend", {
|
|
185
|
-
|
|
185
|
+
assignedVaults: ["work"],
|
|
186
186
|
allowMulti: true,
|
|
187
187
|
});
|
|
188
188
|
const session = createSession(harness.db, { userId: admin.id });
|
|
@@ -757,7 +757,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
757
757
|
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
758
758
|
allowMulti: true,
|
|
759
759
|
passwordChanged: true,
|
|
760
|
-
|
|
760
|
+
assignedVaults: ["alice"],
|
|
761
761
|
});
|
|
762
762
|
const session = createSession(harness.db, { userId: friend.id });
|
|
763
763
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
@@ -774,7 +774,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
774
774
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
|
|
775
775
|
});
|
|
776
776
|
|
|
777
|
-
test("200 + admin branch when the first-admin signs in (
|
|
777
|
+
test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
|
|
778
778
|
// The first-created user with no vault pin is the admin posture.
|
|
779
779
|
const admin = await createUser(harness.db, "admin", "admin-passphrase", {
|
|
780
780
|
passwordChanged: true,
|
|
@@ -126,6 +126,19 @@ function alwaysOkRun(): {
|
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Test default for `isLinked`: assume the package is NOT bun-linked so
|
|
131
|
+
* `runInstall` exercises the `bun add -g` path (which the stubbed runner
|
|
132
|
+
* captures into `calls`). The production default at
|
|
133
|
+
* `src/bun-link.ts` reads the contributor's real `~/.bun/install/global/`
|
|
134
|
+
* symlinks; on Aaron's machine vault/scribe/notes/hub are all linked
|
|
135
|
+
* (the canonical local-dev shape — smoke 2026-05-27 finding 1 caps the
|
|
136
|
+
* fix). Tests asserting "bun add WAS called" must opt out of that leakage
|
|
137
|
+
* by passing this stub. Tests specifically exercising the skip path use
|
|
138
|
+
* an inline `isLinked: () => true` or a per-pkg discriminator.
|
|
139
|
+
*/
|
|
140
|
+
const TEST_DEFAULT_NOT_LINKED = (_pkg: string): boolean => false;
|
|
141
|
+
|
|
129
142
|
describe("parseModulesPath", () => {
|
|
130
143
|
test("recognizes curated short + action", () => {
|
|
131
144
|
expect(parseModulesPath("/api/modules/vault/install")).toEqual({
|
|
@@ -231,6 +244,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
231
244
|
configDir: h.dir,
|
|
232
245
|
supervisor,
|
|
233
246
|
run,
|
|
247
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
234
248
|
},
|
|
235
249
|
);
|
|
236
250
|
expect(res.status).toBe(202);
|
|
@@ -280,6 +294,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
280
294
|
configDir: h.dir,
|
|
281
295
|
supervisor,
|
|
282
296
|
run,
|
|
297
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
283
298
|
},
|
|
284
299
|
);
|
|
285
300
|
expect(res.status).toBe(202);
|
|
@@ -301,6 +316,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
301
316
|
configDir: h.dir,
|
|
302
317
|
supervisor,
|
|
303
318
|
run,
|
|
319
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
304
320
|
},
|
|
305
321
|
);
|
|
306
322
|
const op = (await opRes.json()) as { status: string };
|
|
@@ -324,6 +340,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
324
340
|
configDir: h.dir,
|
|
325
341
|
supervisor,
|
|
326
342
|
run,
|
|
343
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
327
344
|
},
|
|
328
345
|
);
|
|
329
346
|
expect(res.status).toBe(202);
|
|
@@ -348,6 +365,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
348
365
|
configDir: h.dir,
|
|
349
366
|
supervisor,
|
|
350
367
|
run,
|
|
368
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
351
369
|
},
|
|
352
370
|
);
|
|
353
371
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -379,6 +397,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
379
397
|
configDir: h.dir,
|
|
380
398
|
supervisor,
|
|
381
399
|
run,
|
|
400
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
382
401
|
},
|
|
383
402
|
);
|
|
384
403
|
expect(res.status).toBe(202);
|
|
@@ -406,6 +425,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
406
425
|
configDir: h.dir,
|
|
407
426
|
supervisor,
|
|
408
427
|
run,
|
|
428
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
409
429
|
},
|
|
410
430
|
);
|
|
411
431
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -433,6 +453,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
433
453
|
configDir: h.dir,
|
|
434
454
|
supervisor,
|
|
435
455
|
run,
|
|
456
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
436
457
|
},
|
|
437
458
|
);
|
|
438
459
|
expect(res.status).toBe(400);
|
|
@@ -458,6 +479,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
458
479
|
configDir: h.dir,
|
|
459
480
|
supervisor,
|
|
460
481
|
run,
|
|
482
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
461
483
|
},
|
|
462
484
|
);
|
|
463
485
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -487,6 +509,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
487
509
|
configDir: h.dir,
|
|
488
510
|
supervisor,
|
|
489
511
|
run,
|
|
512
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
490
513
|
},
|
|
491
514
|
);
|
|
492
515
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -524,6 +547,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
524
547
|
configDir: h.dir,
|
|
525
548
|
supervisor,
|
|
526
549
|
run,
|
|
550
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
527
551
|
},
|
|
528
552
|
);
|
|
529
553
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -557,6 +581,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
557
581
|
configDir: h.dir,
|
|
558
582
|
supervisor,
|
|
559
583
|
run,
|
|
584
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
560
585
|
},
|
|
561
586
|
);
|
|
562
587
|
expect(res.status).toBe(202);
|
|
@@ -583,6 +608,7 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
583
608
|
supervisor,
|
|
584
609
|
run: async () => 1,
|
|
585
610
|
findGlobalInstall: () => null,
|
|
611
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
586
612
|
};
|
|
587
613
|
const res = await handleInstall(
|
|
588
614
|
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
@@ -602,6 +628,71 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
602
628
|
expect(op.status).toBe("failed");
|
|
603
629
|
expect(op.error).toMatch(/bun add -g exited 1/);
|
|
604
630
|
});
|
|
631
|
+
|
|
632
|
+
test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
|
|
633
|
+
// Smoke finding 1: the wizard's parallel install path was unconditionally
|
|
634
|
+
// invoking `bun add -g <pkg>` even when the package was already linked
|
|
635
|
+
// via `bun link <abspath>` (the standard local-dev shape). At best a
|
|
636
|
+
// wasted ~3s npm round-trip per install; at worst the global bun.lock
|
|
637
|
+
// had unrelated noise and the install failed outright, taking the
|
|
638
|
+
// wizard's vault step with it. Fix: mirror the CLI install path's
|
|
639
|
+
// `isLinked` short-circuit. Regression guard.
|
|
640
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
641
|
+
const { run, calls } = alwaysOkRun();
|
|
642
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
643
|
+
const res = await handleInstall(
|
|
644
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
645
|
+
"vault",
|
|
646
|
+
{
|
|
647
|
+
db: h.db,
|
|
648
|
+
issuer: ISSUER,
|
|
649
|
+
manifestPath: h.manifestPath,
|
|
650
|
+
configDir: h.dir,
|
|
651
|
+
supervisor,
|
|
652
|
+
run,
|
|
653
|
+
// The bug shape: package IS linked locally. Without the
|
|
654
|
+
// short-circuit, runInstall would still call bun add -g.
|
|
655
|
+
isLinked: (pkg) => pkg === "@openparachute/vault",
|
|
656
|
+
},
|
|
657
|
+
);
|
|
658
|
+
expect(res.status).toBe(202);
|
|
659
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
660
|
+
|
|
661
|
+
// The fix: bun add -g was NOT invoked for the linked package.
|
|
662
|
+
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
663
|
+
// Downstream of the skip, the seed + spawn still happen — the install
|
|
664
|
+
// op completes successfully against the locally-linked checkout.
|
|
665
|
+
const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
666
|
+
services: Array<{ name: string }>;
|
|
667
|
+
};
|
|
668
|
+
expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
|
|
669
|
+
expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("still runs bun add -g when package is NOT bun-linked", async () => {
|
|
673
|
+
// Companion to the above — confirms the short-circuit doesn't
|
|
674
|
+
// unconditionally skip. On a friend's fresh machine (no bun link),
|
|
675
|
+
// bun add -g IS what installs the package from npm. `isLinked: () => false`
|
|
676
|
+
// is the production default behavior for a non-linked package.
|
|
677
|
+
const { supervisor } = makeIdleSupervisor();
|
|
678
|
+
const { run, calls } = alwaysOkRun();
|
|
679
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
680
|
+
await handleInstall(
|
|
681
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
682
|
+
"vault",
|
|
683
|
+
{
|
|
684
|
+
db: h.db,
|
|
685
|
+
issuer: ISSUER,
|
|
686
|
+
manifestPath: h.manifestPath,
|
|
687
|
+
configDir: h.dir,
|
|
688
|
+
supervisor,
|
|
689
|
+
run,
|
|
690
|
+
isLinked: () => false,
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
694
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
695
|
+
});
|
|
605
696
|
});
|
|
606
697
|
|
|
607
698
|
describe("POST /api/modules/:short/restart", () => {
|
|
@@ -682,6 +773,7 @@ describe("POST /api/modules/:short/upgrade", () => {
|
|
|
682
773
|
configDir: h.dir,
|
|
683
774
|
supervisor,
|
|
684
775
|
run,
|
|
776
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
685
777
|
},
|
|
686
778
|
);
|
|
687
779
|
expect(res.status).toBe(202);
|
|
@@ -706,6 +798,7 @@ describe("POST /api/modules/:short/upgrade", () => {
|
|
|
706
798
|
configDir: h.dir,
|
|
707
799
|
supervisor,
|
|
708
800
|
run,
|
|
801
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
709
802
|
},
|
|
710
803
|
);
|
|
711
804
|
expect(res.status).toBe(202);
|
|
@@ -736,6 +829,7 @@ describe("POST /api/modules/:short/upgrade", () => {
|
|
|
736
829
|
configDir: h.dir,
|
|
737
830
|
supervisor,
|
|
738
831
|
run,
|
|
832
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
739
833
|
},
|
|
740
834
|
);
|
|
741
835
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -846,6 +940,7 @@ describe("POST /api/modules/:short/uninstall", () => {
|
|
|
846
940
|
configDir: h.dir,
|
|
847
941
|
supervisor,
|
|
848
942
|
run,
|
|
943
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
849
944
|
},
|
|
850
945
|
);
|
|
851
946
|
expect(res.status).toBe(200);
|
|
@@ -878,6 +973,7 @@ describe("POST /api/modules/:short/uninstall", () => {
|
|
|
878
973
|
configDir: h.dir,
|
|
879
974
|
supervisor,
|
|
880
975
|
run,
|
|
976
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
881
977
|
},
|
|
882
978
|
);
|
|
883
979
|
expect(res.status).toBe(200);
|
|
@@ -1099,6 +1195,7 @@ describe("well-known regen after module ops", () => {
|
|
|
1099
1195
|
supervisor,
|
|
1100
1196
|
run: async () => 1,
|
|
1101
1197
|
findGlobalInstall: () => null,
|
|
1198
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
1102
1199
|
wellKnownPath: wkPath,
|
|
1103
1200
|
};
|
|
1104
1201
|
const res = await handleInstall(
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
handleListUsers,
|
|
32
32
|
handleListVaults,
|
|
33
33
|
handleResetUserPassword,
|
|
34
|
+
handleUpdateUserVaults,
|
|
34
35
|
} from "../api-users.ts";
|
|
35
36
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
36
37
|
import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
@@ -176,7 +177,7 @@ describe("handleListUsers", () => {
|
|
|
176
177
|
expect(u).toHaveProperty("id");
|
|
177
178
|
expect(u).toHaveProperty("username");
|
|
178
179
|
expect(u).toHaveProperty("password_changed");
|
|
179
|
-
expect(u).toHaveProperty("
|
|
180
|
+
expect(u).toHaveProperty("assigned_vaults");
|
|
180
181
|
expect(u).toHaveProperty("created_at");
|
|
181
182
|
}
|
|
182
183
|
});
|
|
@@ -220,13 +221,13 @@ describe("handleCreateUser", () => {
|
|
|
220
221
|
const res = await post(bearer, {
|
|
221
222
|
username: "alice",
|
|
222
223
|
password: "alice-strong-passphrase",
|
|
223
|
-
|
|
224
|
+
assignedVaults: [],
|
|
224
225
|
});
|
|
225
226
|
expect(res.status).toBe(201);
|
|
226
227
|
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
227
228
|
expect(body.user.username).toBe("alice");
|
|
228
229
|
expect(body.user.password_changed).toBe(false);
|
|
229
|
-
expect(body.user.
|
|
230
|
+
expect(body.user.assigned_vaults).toEqual([]);
|
|
230
231
|
expect(body.user).not.toHaveProperty("password_hash");
|
|
231
232
|
});
|
|
232
233
|
|
|
@@ -331,9 +332,9 @@ describe("handleCreateUser", () => {
|
|
|
331
332
|
const stamp = "2026-05-20T00:00:00.000Z";
|
|
332
333
|
harness.db
|
|
333
334
|
.prepare(
|
|
334
|
-
"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 (?, ?, ?, ?, ?, ?)",
|
|
335
336
|
)
|
|
336
|
-
.run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1
|
|
337
|
+
.run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1);
|
|
337
338
|
const { bearer } = await makeAdminBearer();
|
|
338
339
|
const res = await post(bearer, {
|
|
339
340
|
username: "alice",
|
|
@@ -349,25 +350,55 @@ describe("handleCreateUser", () => {
|
|
|
349
350
|
const res = await post(bearer, {
|
|
350
351
|
username: "alice",
|
|
351
352
|
password: "alice-strong-passphrase",
|
|
352
|
-
|
|
353
|
+
assignedVaults: ["ghost-vault"],
|
|
353
354
|
});
|
|
354
355
|
expect(res.status).toBe(400);
|
|
355
356
|
const body = (await res.json()) as { error: string };
|
|
356
357
|
expect(body.error).toBe("assigned_vault_not_found");
|
|
357
358
|
});
|
|
358
359
|
|
|
359
|
-
test("happy path with
|
|
360
|
+
test("happy path with single assigned_vaults that exists in services.json", async () => {
|
|
360
361
|
harness.cleanup();
|
|
361
362
|
harness = makeHarness(manifestWithVaults("home"));
|
|
362
363
|
const { bearer } = await makeAdminBearer();
|
|
363
364
|
const res = await post(bearer, {
|
|
364
365
|
username: "alice",
|
|
365
366
|
password: "alice-strong-passphrase",
|
|
366
|
-
|
|
367
|
+
assignedVaults: ["home"],
|
|
367
368
|
});
|
|
368
369
|
expect(res.status).toBe(201);
|
|
369
370
|
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
370
|
-
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");
|
|
371
402
|
});
|
|
372
403
|
});
|
|
373
404
|
|
|
@@ -632,6 +663,30 @@ describe("handleResetUserPassword", () => {
|
|
|
632
663
|
expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
|
|
633
664
|
});
|
|
634
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
|
+
|
|
635
690
|
test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
|
|
636
691
|
const { bearer } = await makeAdminBearer();
|
|
637
692
|
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
@@ -710,3 +765,130 @@ describe("handleListVaults", () => {
|
|
|
710
765
|
expect(body.vaults).toEqual(["home", "scratch", "work"]);
|
|
711
766
|
});
|
|
712
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
|
+
});
|