@openparachute/hub 0.3.0-rc.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,541 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { UpgradeRunner } from "../commands/upgrade.ts";
6
+ import { upgrade } from "../commands/upgrade.ts";
7
+ import { upsertService } from "../services-manifest.ts";
8
+
9
+ interface RunCall {
10
+ cmd: string[];
11
+ cwd?: string;
12
+ kind: "run" | "capture";
13
+ }
14
+
15
+ interface MockRunner {
16
+ runner: UpgradeRunner;
17
+ calls: RunCall[];
18
+ }
19
+
20
+ /**
21
+ * Build a runner stub that scripts responses by command-prefix match. The
22
+ * matcher walks the responses array in order; the first entry whose `match`
23
+ * function returns true wins. Unmatched commands return code 0 / empty
24
+ * stdout, which keeps the happy path quiet.
25
+ */
26
+ function makeRunner(
27
+ responses: Array<{
28
+ match: (cmd: readonly string[]) => boolean;
29
+ code?: number;
30
+ stdout?: string;
31
+ }> = [],
32
+ ): MockRunner {
33
+ const calls: RunCall[] = [];
34
+ const find = (cmd: readonly string[]) => responses.find((r) => r.match(cmd));
35
+ return {
36
+ calls,
37
+ runner: {
38
+ async run(cmd, opts) {
39
+ calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "run" });
40
+ return find(cmd)?.code ?? 0;
41
+ },
42
+ async capture(cmd, opts) {
43
+ calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "capture" });
44
+ const r = find(cmd);
45
+ return { code: r?.code ?? 0, stdout: r?.stdout ?? "" };
46
+ },
47
+ },
48
+ };
49
+ }
50
+
51
+ interface Harness {
52
+ configDir: string;
53
+ manifestPath: string;
54
+ installRoot: string;
55
+ cleanup: () => void;
56
+ }
57
+
58
+ function makeHarness(): Harness {
59
+ const dir = mkdtempSync(join(tmpdir(), "pcli-upgrade-"));
60
+ return {
61
+ configDir: dir,
62
+ manifestPath: join(dir, "services.json"),
63
+ installRoot: join(dir, "installs"),
64
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
65
+ };
66
+ }
67
+
68
+ function writePackageJson(dir: string, body: Record<string, unknown>): void {
69
+ mkdirSync(dir, { recursive: true });
70
+ writeFileSync(join(dir, "package.json"), JSON.stringify(body, null, 2));
71
+ }
72
+
73
+ function seedVault(manifestPath: string, installDir: string, version = "0.4.0"): void {
74
+ upsertService(
75
+ {
76
+ name: "parachute-vault",
77
+ port: 1940,
78
+ paths: ["/vault/default"],
79
+ health: "/vault/default/health",
80
+ version,
81
+ installDir,
82
+ },
83
+ manifestPath,
84
+ );
85
+ }
86
+
87
+ describe("parachute upgrade", () => {
88
+ test("errors cleanly when no services installed", async () => {
89
+ const h = makeHarness();
90
+ try {
91
+ const logs: string[] = [];
92
+ const m = makeRunner();
93
+ const code = await upgrade(undefined, {
94
+ manifestPath: h.manifestPath,
95
+ configDir: h.configDir,
96
+ runner: m.runner,
97
+ findGlobalInstall: () => null,
98
+ restartFn: async () => 0,
99
+ log: (l) => logs.push(l),
100
+ });
101
+ expect(code).toBe(1);
102
+ expect(logs.join("\n")).toMatch(/No services installed/);
103
+ } finally {
104
+ h.cleanup();
105
+ }
106
+ });
107
+
108
+ test("errors cleanly on unknown service", async () => {
109
+ const h = makeHarness();
110
+ try {
111
+ seedVault(h.manifestPath, join(h.installRoot, "vault"));
112
+ const logs: string[] = [];
113
+ const m = makeRunner();
114
+ const code = await upgrade("nope", {
115
+ manifestPath: h.manifestPath,
116
+ configDir: h.configDir,
117
+ runner: m.runner,
118
+ findGlobalInstall: () => null,
119
+ restartFn: async () => 0,
120
+ log: (l) => logs.push(l),
121
+ });
122
+ expect(code).toBe(1);
123
+ expect(logs.join("\n")).toMatch(/unknown service/);
124
+ } finally {
125
+ h.cleanup();
126
+ }
127
+ });
128
+
129
+ test("bun-linked happy path: pulls, reinstalls deps, restarts", async () => {
130
+ const h = makeHarness();
131
+ try {
132
+ const installDir = join(h.installRoot, "vault");
133
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
134
+ seedVault(h.manifestPath, installDir);
135
+
136
+ const m = makeRunner([
137
+ {
138
+ match: (c) => c[0] === "git" && c[1] === "rev-parse" && c[2] === "--is-inside-work-tree",
139
+ code: 0,
140
+ },
141
+ {
142
+ match: (c) => c[0] === "git" && c[1] === "status" && c[2] === "--porcelain",
143
+ code: 0,
144
+ stdout: "",
145
+ },
146
+ // First HEAD read (before pull) — old SHA
147
+ // Sequence: capture matchers fire in order; we use a stateful counter
148
+ ]);
149
+
150
+ // Stateful HEAD: first capture returns "abc", second returns "def"
151
+ let headCalls = 0;
152
+ const runner: UpgradeRunner = {
153
+ async run(cmd, opts) {
154
+ m.calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "run" });
155
+ if (cmd[0] === "git" && cmd[1] === "pull") return 0;
156
+ if (cmd[0] === "bun" && cmd[1] === "install") return 0;
157
+ return 0;
158
+ },
159
+ async capture(cmd, opts) {
160
+ m.calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "capture" });
161
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
162
+ return { code: 0, stdout: "true" };
163
+ }
164
+ if (cmd[1] === "status") return { code: 0, stdout: "" };
165
+ if (cmd[1] === "rev-parse" && cmd[2] === "HEAD") {
166
+ headCalls++;
167
+ return { code: 0, stdout: headCalls === 1 ? "aaaaaaa" : "bbbbbbb" };
168
+ }
169
+ if (cmd[1] === "diff") {
170
+ return { code: 0, stdout: "package.json\nsrc/foo.ts" };
171
+ }
172
+ return { code: 0, stdout: "" };
173
+ },
174
+ };
175
+
176
+ let restartedShort: string | undefined;
177
+ const logs: string[] = [];
178
+ const code = await upgrade("vault", {
179
+ manifestPath: h.manifestPath,
180
+ configDir: h.configDir,
181
+ runner,
182
+ findGlobalInstall: () => join(installDir, "package.json"),
183
+ restartFn: async (svc) => {
184
+ restartedShort = svc;
185
+ return 0;
186
+ },
187
+ log: (l) => logs.push(l),
188
+ });
189
+ expect(code).toBe(0);
190
+ expect(restartedShort).toBe("vault");
191
+ const joined = logs.join("\n");
192
+ expect(joined).toMatch(/bun-linked checkout/);
193
+ expect(joined).toMatch(/git pull --ff-only/);
194
+ expect(joined).toMatch(/bun install --frozen-lockfile/);
195
+ expect(joined).toMatch(/aaaaaa.*→.*bbbbbb/);
196
+ expect(joined).toMatch(/restarting/);
197
+ } finally {
198
+ h.cleanup();
199
+ }
200
+ });
201
+
202
+ test("bun-linked, HEAD unchanged: no-op skip-restart", async () => {
203
+ const h = makeHarness();
204
+ try {
205
+ const installDir = join(h.installRoot, "vault");
206
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
207
+ seedVault(h.manifestPath, installDir);
208
+
209
+ const runner: UpgradeRunner = {
210
+ async run() {
211
+ return 0;
212
+ },
213
+ async capture(cmd) {
214
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
215
+ return { code: 0, stdout: "true" };
216
+ }
217
+ if (cmd[1] === "status") return { code: 0, stdout: "" };
218
+ if (cmd[1] === "rev-parse" && cmd[2] === "HEAD") {
219
+ return { code: 0, stdout: "abcdef0" };
220
+ }
221
+ return { code: 0, stdout: "" };
222
+ },
223
+ };
224
+
225
+ let restartCalled = false;
226
+ const logs: string[] = [];
227
+ const code = await upgrade("vault", {
228
+ manifestPath: h.manifestPath,
229
+ configDir: h.configDir,
230
+ runner,
231
+ findGlobalInstall: () => join(installDir, "package.json"),
232
+ restartFn: async () => {
233
+ restartCalled = true;
234
+ return 0;
235
+ },
236
+ log: (l) => logs.push(l),
237
+ });
238
+ expect(code).toBe(0);
239
+ expect(restartCalled).toBe(false);
240
+ expect(logs.join("\n")).toMatch(/already up to date/);
241
+ } finally {
242
+ h.cleanup();
243
+ }
244
+ });
245
+
246
+ test("bun-linked refuses on dirty working tree", async () => {
247
+ const h = makeHarness();
248
+ try {
249
+ const installDir = join(h.installRoot, "vault");
250
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
251
+ seedVault(h.manifestPath, installDir);
252
+
253
+ const runner: UpgradeRunner = {
254
+ async run() {
255
+ return 0;
256
+ },
257
+ async capture(cmd) {
258
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
259
+ return { code: 0, stdout: "true" };
260
+ }
261
+ if (cmd[1] === "status") {
262
+ return { code: 0, stdout: " M src/foo.ts\n" };
263
+ }
264
+ return { code: 0, stdout: "" };
265
+ },
266
+ };
267
+
268
+ let restartCalled = false;
269
+ const logs: string[] = [];
270
+ const code = await upgrade("vault", {
271
+ manifestPath: h.manifestPath,
272
+ configDir: h.configDir,
273
+ runner,
274
+ findGlobalInstall: () => join(installDir, "package.json"),
275
+ restartFn: async () => {
276
+ restartCalled = true;
277
+ return 0;
278
+ },
279
+ log: (l) => logs.push(l),
280
+ });
281
+ expect(code).toBe(1);
282
+ expect(restartCalled).toBe(false);
283
+ expect(logs.join("\n")).toMatch(/dirty working tree/);
284
+ } finally {
285
+ h.cleanup();
286
+ }
287
+ });
288
+
289
+ test("bun-linked frontend: runs bun run build before restart", async () => {
290
+ const h = makeHarness();
291
+ try {
292
+ const installDir = join(h.installRoot, "notes");
293
+ writePackageJson(installDir, {
294
+ name: "@openparachute/notes",
295
+ version: "0.0.1",
296
+ scripts: { build: "vite build" },
297
+ });
298
+ upsertService(
299
+ {
300
+ name: "parachute-notes",
301
+ port: 1942,
302
+ paths: ["/notes"],
303
+ health: "/notes/health",
304
+ version: "0.0.1",
305
+ installDir,
306
+ },
307
+ h.manifestPath,
308
+ );
309
+
310
+ let headCalls = 0;
311
+ const ranBuild = { value: false };
312
+ const runner: UpgradeRunner = {
313
+ async run(cmd) {
314
+ if (cmd[0] === "bun" && cmd[1] === "run" && cmd[2] === "build") {
315
+ ranBuild.value = true;
316
+ }
317
+ return 0;
318
+ },
319
+ async capture(cmd) {
320
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
321
+ return { code: 0, stdout: "true" };
322
+ }
323
+ if (cmd[1] === "status") return { code: 0, stdout: "" };
324
+ if (cmd[1] === "rev-parse" && cmd[2] === "HEAD") {
325
+ headCalls++;
326
+ return { code: 0, stdout: headCalls === 1 ? "111" : "222" };
327
+ }
328
+ if (cmd[1] === "diff") return { code: 0, stdout: "src/x.ts" };
329
+ return { code: 0, stdout: "" };
330
+ },
331
+ };
332
+
333
+ const code = await upgrade("notes", {
334
+ manifestPath: h.manifestPath,
335
+ configDir: h.configDir,
336
+ runner,
337
+ findGlobalInstall: () => join(installDir, "package.json"),
338
+ restartFn: async () => 0,
339
+ log: () => {},
340
+ });
341
+ expect(code).toBe(0);
342
+ expect(ranBuild.value).toBe(true);
343
+ } finally {
344
+ h.cleanup();
345
+ }
346
+ });
347
+
348
+ test("npm-installed happy path: bun add -g, version bumps, restarts", async () => {
349
+ const h = makeHarness();
350
+ try {
351
+ const installDir = join(h.installRoot, "vault");
352
+ // Initial version 0.4.0
353
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
354
+ seedVault(h.manifestPath, installDir, "0.4.0");
355
+
356
+ const runner: UpgradeRunner = {
357
+ async run(cmd) {
358
+ // Simulate `bun add -g` rewriting the package.json with a new version
359
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
360
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.5.0" });
361
+ }
362
+ return 0;
363
+ },
364
+ async capture(cmd) {
365
+ // Not a git checkout
366
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
367
+ return { code: 128, stdout: "fatal: not a git repository\n" };
368
+ }
369
+ return { code: 0, stdout: "" };
370
+ },
371
+ };
372
+
373
+ let restartedShort: string | undefined;
374
+ const logs: string[] = [];
375
+ const code = await upgrade("vault", {
376
+ manifestPath: h.manifestPath,
377
+ configDir: h.configDir,
378
+ runner,
379
+ findGlobalInstall: () => join(installDir, "package.json"),
380
+ restartFn: async (svc) => {
381
+ restartedShort = svc;
382
+ return 0;
383
+ },
384
+ log: (l) => logs.push(l),
385
+ });
386
+ expect(code).toBe(0);
387
+ expect(restartedShort).toBe("vault");
388
+ const joined = logs.join("\n");
389
+ expect(joined).toMatch(/npm-installed/);
390
+ expect(joined).toMatch(/bun add -g @openparachute\/vault@latest/);
391
+ expect(joined).toMatch(/0\.4\.0 → 0\.5\.0/);
392
+ } finally {
393
+ h.cleanup();
394
+ }
395
+ });
396
+
397
+ test("npm-installed: version unchanged → skip restart", async () => {
398
+ const h = makeHarness();
399
+ try {
400
+ const installDir = join(h.installRoot, "vault");
401
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
402
+ seedVault(h.manifestPath, installDir, "0.4.0");
403
+
404
+ // Don't change package.json on bun add -g — same version after.
405
+ const runner: UpgradeRunner = {
406
+ async run() {
407
+ return 0;
408
+ },
409
+ async capture(cmd) {
410
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
411
+ return { code: 128, stdout: "" };
412
+ }
413
+ return { code: 0, stdout: "" };
414
+ },
415
+ };
416
+
417
+ let restartCalled = false;
418
+ const logs: string[] = [];
419
+ const code = await upgrade("vault", {
420
+ manifestPath: h.manifestPath,
421
+ configDir: h.configDir,
422
+ runner,
423
+ findGlobalInstall: () => join(installDir, "package.json"),
424
+ restartFn: async () => {
425
+ restartCalled = true;
426
+ return 0;
427
+ },
428
+ log: (l) => logs.push(l),
429
+ });
430
+ expect(code).toBe(0);
431
+ expect(restartCalled).toBe(false);
432
+ expect(logs.join("\n")).toMatch(/already at 0\.4\.0/);
433
+ } finally {
434
+ h.cleanup();
435
+ }
436
+ });
437
+
438
+ test("npm-installed: --tag is forwarded to bun add -g", async () => {
439
+ const h = makeHarness();
440
+ try {
441
+ const installDir = join(h.installRoot, "vault");
442
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
443
+ seedVault(h.manifestPath, installDir, "0.4.0");
444
+
445
+ const seenCmd: string[][] = [];
446
+ const runner: UpgradeRunner = {
447
+ async run(cmd) {
448
+ seenCmd.push([...cmd]);
449
+ return 0;
450
+ },
451
+ async capture(cmd) {
452
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
453
+ return { code: 128, stdout: "" };
454
+ }
455
+ return { code: 0, stdout: "" };
456
+ },
457
+ };
458
+
459
+ await upgrade("vault", {
460
+ manifestPath: h.manifestPath,
461
+ configDir: h.configDir,
462
+ runner,
463
+ findGlobalInstall: () => join(installDir, "package.json"),
464
+ restartFn: async () => 0,
465
+ tag: "rc",
466
+ log: () => {},
467
+ });
468
+ const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
469
+ expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
470
+ } finally {
471
+ h.cleanup();
472
+ }
473
+ });
474
+
475
+ test("sweep (no svc): partial failure — later targets still run; first failure code wins", async () => {
476
+ const h = makeHarness();
477
+ try {
478
+ const vaultDir = join(h.installRoot, "vault");
479
+ const notesDir = join(h.installRoot, "notes");
480
+ writePackageJson(vaultDir, { name: "@openparachute/vault", version: "0.4.0" });
481
+ writePackageJson(notesDir, { name: "@openparachute/notes", version: "0.0.1" });
482
+ seedVault(h.manifestPath, vaultDir);
483
+ upsertService(
484
+ {
485
+ name: "parachute-notes",
486
+ port: 1942,
487
+ paths: ["/notes"],
488
+ health: "/notes/health",
489
+ version: "0.0.1",
490
+ installDir: notesDir,
491
+ },
492
+ h.manifestPath,
493
+ );
494
+
495
+ // vault is npm-installed (no git); bun add -g fails with 7
496
+ // notes is npm-installed and succeeds with version bump
497
+ const runner: UpgradeRunner = {
498
+ async run(cmd, opts) {
499
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
500
+ const pkg = cmd[3] ?? "";
501
+ if (pkg.startsWith("@openparachute/vault")) return 7;
502
+ if (pkg.startsWith("@openparachute/notes")) {
503
+ writePackageJson(notesDir, { name: "@openparachute/notes", version: "0.1.0" });
504
+ return 0;
505
+ }
506
+ }
507
+ return 0;
508
+ },
509
+ async capture(cmd) {
510
+ if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
511
+ return { code: 128, stdout: "" };
512
+ }
513
+ return { code: 0, stdout: "" };
514
+ },
515
+ };
516
+
517
+ const restartCalls: string[] = [];
518
+ const logs: string[] = [];
519
+ const code = await upgrade(undefined, {
520
+ manifestPath: h.manifestPath,
521
+ configDir: h.configDir,
522
+ runner,
523
+ findGlobalInstall: (pkg) => {
524
+ if (pkg === "@openparachute/vault") return join(vaultDir, "package.json");
525
+ if (pkg === "@openparachute/notes") return join(notesDir, "package.json");
526
+ return null;
527
+ },
528
+ restartFn: async (svc) => {
529
+ restartCalls.push(svc);
530
+ return 0;
531
+ },
532
+ log: (l) => logs.push(l),
533
+ });
534
+ expect(code).toBe(7);
535
+ expect(restartCalls).toEqual(["notes"]);
536
+ expect(logs.join("\n")).toMatch(/vault: bun add -g failed \(exit 7\)/);
537
+ } finally {
538
+ h.cleanup();
539
+ }
540
+ });
541
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+ import {
7
+ SingleUserModeError,
8
+ UserNotFoundError,
9
+ UsernameTakenError,
10
+ createUser,
11
+ getUserById,
12
+ getUserByUsername,
13
+ listUsers,
14
+ setPassword,
15
+ userCount,
16
+ verifyPassword,
17
+ } from "../users.ts";
18
+
19
+ function makeDb() {
20
+ const configDir = mkdtempSync(join(tmpdir(), "phub-users-"));
21
+ const db = openHubDb(hubDbPath(configDir));
22
+ return {
23
+ db,
24
+ cleanup: () => {
25
+ db.close();
26
+ rmSync(configDir, { recursive: true, force: true });
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("createUser", () => {
32
+ test("creates a user and stores an argon2id hash", async () => {
33
+ const { db, cleanup } = makeDb();
34
+ try {
35
+ const u = await createUser(db, "owner", "hunter2");
36
+ expect(u.username).toBe("owner");
37
+ expect(u.id.length).toBeGreaterThan(0);
38
+ // Argon2id encoded form starts with $argon2id$.
39
+ expect(u.passwordHash.startsWith("$argon2id$")).toBe(true);
40
+ expect(u.createdAt).toBe(u.updatedAt);
41
+ expect(userCount(db)).toBe(1);
42
+ } finally {
43
+ cleanup();
44
+ }
45
+ });
46
+
47
+ test("refuses a second user without --allow-multi (single-user mode)", async () => {
48
+ const { db, cleanup } = makeDb();
49
+ try {
50
+ await createUser(db, "owner", "pw1");
51
+ await expect(createUser(db, "second", "pw2")).rejects.toThrow(SingleUserModeError);
52
+ expect(userCount(db)).toBe(1);
53
+ } finally {
54
+ cleanup();
55
+ }
56
+ });
57
+
58
+ test("allows a second user when allowMulti is true", async () => {
59
+ const { db, cleanup } = makeDb();
60
+ try {
61
+ await createUser(db, "owner", "pw1");
62
+ const second = await createUser(db, "second", "pw2", { allowMulti: true });
63
+ expect(second.username).toBe("second");
64
+ expect(userCount(db)).toBe(2);
65
+ } finally {
66
+ cleanup();
67
+ }
68
+ });
69
+
70
+ test("refuses a duplicate username with UsernameTakenError", async () => {
71
+ const { db, cleanup } = makeDb();
72
+ try {
73
+ await createUser(db, "owner", "pw1");
74
+ await expect(createUser(db, "owner", "pw2", { allowMulti: true })).rejects.toThrow(
75
+ UsernameTakenError,
76
+ );
77
+ } finally {
78
+ cleanup();
79
+ }
80
+ });
81
+ });
82
+
83
+ describe("verifyPassword", () => {
84
+ test("true for the original password, false for anything else", async () => {
85
+ const { db, cleanup } = makeDb();
86
+ try {
87
+ const u = await createUser(db, "owner", "correct horse");
88
+ expect(await verifyPassword(u, "correct horse")).toBe(true);
89
+ expect(await verifyPassword(u, "wrong")).toBe(false);
90
+ expect(await verifyPassword(u, "")).toBe(false);
91
+ } finally {
92
+ cleanup();
93
+ }
94
+ });
95
+ });
96
+
97
+ describe("setPassword", () => {
98
+ test("rotates the hash and updates updated_at", async () => {
99
+ const { db, cleanup } = makeDb();
100
+ try {
101
+ const u = await createUser(db, "owner", "old-pw");
102
+ const oldHash = u.passwordHash;
103
+ const oldUpdated = u.updatedAt;
104
+ // Bump the clock so the timestamp visibly changes.
105
+ const later = new Date(new Date(oldUpdated).getTime() + 1000);
106
+ await setPassword(db, u.id, "new-pw", () => later);
107
+ const fresh = getUserById(db, u.id);
108
+ expect(fresh).not.toBeNull();
109
+ expect(fresh?.passwordHash).not.toBe(oldHash);
110
+ expect(fresh?.updatedAt).not.toBe(oldUpdated);
111
+ expect(await verifyPassword(fresh!, "new-pw")).toBe(true);
112
+ expect(await verifyPassword(fresh!, "old-pw")).toBe(false);
113
+ } finally {
114
+ cleanup();
115
+ }
116
+ });
117
+
118
+ test("throws UserNotFoundError for an unknown id", async () => {
119
+ const { db, cleanup } = makeDb();
120
+ try {
121
+ await expect(setPassword(db, "no-such-user", "pw")).rejects.toThrow(UserNotFoundError);
122
+ } finally {
123
+ cleanup();
124
+ }
125
+ });
126
+ });
127
+
128
+ describe("listUsers / getUserByUsername", () => {
129
+ test("listUsers returns rows in created_at order", async () => {
130
+ const { db, cleanup } = makeDb();
131
+ try {
132
+ const a = await createUser(db, "a", "pw", { now: () => new Date(1000) });
133
+ const b = await createUser(db, "b", "pw", {
134
+ allowMulti: true,
135
+ now: () => new Date(2000),
136
+ });
137
+ const list = listUsers(db);
138
+ expect(list.map((u) => u.username)).toEqual([a.username, b.username]);
139
+ } finally {
140
+ cleanup();
141
+ }
142
+ });
143
+
144
+ test("getUserByUsername returns null when missing", async () => {
145
+ const { db, cleanup } = makeDb();
146
+ try {
147
+ expect(getUserByUsername(db, "nobody")).toBeNull();
148
+ await createUser(db, "owner", "pw");
149
+ expect(getUserByUsername(db, "owner")?.username).toBe("owner");
150
+ } finally {
151
+ cleanup();
152
+ }
153
+ });
154
+ });