@openparachute/hub 0.5.14-rc.7 → 0.5.14-rc.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.7",
3
+ "version": "0.5.14-rc.8",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -24,6 +24,16 @@ function makeHarness(): Harness {
24
24
  };
25
25
  }
26
26
 
27
+ /**
28
+ * Default test-stub for the vault-module install step (hub#168 Cut 1).
29
+ * The real `installVaultModuleImpl` shells out to `bun add -g
30
+ * @openparachute/vault` + seeds services.json — neither is appropriate in
31
+ * a unit test (slow + side-effectful + leaks state across runs). Tests
32
+ * that want to observe install-flow side-effects (services.json shape,
33
+ * etc.) can override this with their own stub.
34
+ */
35
+ const noopVaultInstall = async (_configDir: string, _manifestPath: string): Promise<number> => 0;
36
+
27
37
  function seedVault(manifestPath: string): void {
28
38
  writeFileSync(
29
39
  manifestPath,
@@ -88,6 +98,7 @@ describe("init", () => {
88
98
  readExposeStateFn: () => undefined,
89
99
  isTty: false,
90
100
  platform: "linux",
101
+ installVaultModuleImpl: noopVaultInstall,
91
102
  });
92
103
  expect(code).toBe(0);
93
104
  expect(calls).toEqual(["ensureHub"]);
@@ -124,6 +135,7 @@ describe("init", () => {
124
135
  readExposeStateFn: () => undefined,
125
136
  isTty: false,
126
137
  platform: "linux",
138
+ installVaultModuleImpl: noopVaultInstall,
127
139
  });
128
140
  expect(code).toBe(0);
129
141
  // Hub was already running — ensureHub should not have been called.
@@ -196,6 +208,10 @@ describe("init", () => {
196
208
  },
197
209
  // Skip the new exposure prompt — this test is about the browser prompt only.
198
210
  noExposePrompt: true,
211
+ // Pre-pick the browser wizard so the new (hub#168 Cut 4) "browser
212
+ // or CLI?" prompt doesn't fire — this test predates that step.
213
+ wizardChoice: "browser",
214
+ installVaultModuleImpl: noopVaultInstall,
199
215
  });
200
216
  expect(code).toBe(0);
201
217
  expect(opened).toEqual(["http://127.0.0.1:1939/admin/"]);
@@ -224,6 +240,13 @@ describe("init", () => {
224
240
  return true;
225
241
  },
226
242
  noExposePrompt: true,
243
+ // No wizardChoice set — falls into the back-compat Y/n confirm,
244
+ // where 'n' skips the browser open (the original semantic this
245
+ // test was written to assert). Suppress the new (hub#168 Cut 4)
246
+ // wizard-choice prompt so this test stays focused on the Y/n
247
+ // confirm path.
248
+ noWizardPrompt: true,
249
+ installVaultModuleImpl: noopVaultInstall,
227
250
  });
228
251
  expect(code).toBe(0);
229
252
  expect(opened).toEqual([]);
@@ -337,6 +360,7 @@ describe("init", () => {
337
360
  readExposeStateFn: () => undefined,
338
361
  isTty: false,
339
362
  platform: "linux",
363
+ installVaultModuleImpl: noopVaultInstall,
340
364
  });
341
365
  expect(code).toBe(1);
342
366
  const joined = logs.join("\n");
@@ -437,6 +461,11 @@ describe("init exposure chain", () => {
437
461
  return 0;
438
462
  },
439
463
  noExposePrompt: true,
464
+ // Suppress the new wizard-choice prompt + stub the vault-module
465
+ // install (hub#168 Cuts 1/4) so this pre-existing test stays
466
+ // focused on the exposure-prompt-skipped assertion.
467
+ noWizardPrompt: true,
468
+ installVaultModuleImpl: noopVaultInstall,
440
469
  });
441
470
  expect(code).toBe(0);
442
471
  expect(exposureChained).toBe(false);
@@ -478,6 +507,8 @@ describe("init exposure chain", () => {
478
507
  return 0;
479
508
  },
480
509
  exposeChoice: "tailnet",
510
+ noWizardPrompt: true,
511
+ installVaultModuleImpl: noopVaultInstall,
481
512
  });
482
513
  expect(code).toBe(0);
483
514
  expect(tailnetCalls).toBe(1);
@@ -677,6 +708,8 @@ describe("init exposure chain", () => {
677
708
  chained = true;
678
709
  return 0;
679
710
  },
711
+ noWizardPrompt: true,
712
+ installVaultModuleImpl: noopVaultInstall,
680
713
  });
681
714
  expect(code).toBe(0);
682
715
  // No exposure chain ran, no exposure prompt asked.
@@ -36,6 +36,7 @@ import {
36
36
  handleSetupGet,
37
37
  handleSetupInstallPost,
38
38
  handleSetupVaultPost,
39
+ postVaultImportImpl,
39
40
  } from "../setup-wizard.ts";
40
41
  import { Supervisor } from "../supervisor.ts";
41
42
  import { createUser, getUserByUsername, userCount } from "../users.ts";
@@ -774,10 +775,15 @@ describe("handleSetupVaultPost", () => {
774
775
  });
775
776
  afterEach(() => h.cleanup());
776
777
 
777
- test("requires a supervisor (CLI mode rejects)", async () => {
778
+ test("requires a supervisor (CLI mode rejects create/import; allows skip — hub#168 Cut 2)", async () => {
778
779
  const db = openHubDb(hubDbPath(h.dir));
779
780
  try {
780
781
  await createUser(db, "owner", "pw");
782
+ // Bare POST (no CSRF, no session) still 400s, but on the new
783
+ // CSRF-first ordering it stops at the CSRF check rather than the
784
+ // supervisor check. That's correct posture — refuse the
785
+ // unauthenticated request before tendering an architectural
786
+ // explanation.
781
787
  const post = await handleSetupVaultPost(
782
788
  req("/admin/setup/vault", {
783
789
  method: "POST",
@@ -794,7 +800,8 @@ describe("handleSetupVaultPost", () => {
794
800
  );
795
801
  expect(post.status).toBe(400);
796
802
  const html = await post.text();
797
- expect(html).toContain("supervisor unavailable");
803
+ // CSRF-first: the bare request bounces at the CSRF gate.
804
+ expect(html).toContain("Invalid form submission");
798
805
  } finally {
799
806
  db.close();
800
807
  }
@@ -3564,3 +3571,221 @@ describe("detectAutoExposeMode — Fly env detection (patterns#100)", () => {
3564
3571
  ).toBe("public");
3565
3572
  });
3566
3573
  });
3574
+
3575
+ // hub#168 Cut 2/3: vault-step three branches (create/import/skip) + JSON
3576
+ // content-type acceptance. The handleSetupVaultPost handler is shared
3577
+ // between browser and CLI surfaces — branching is by mode field +
3578
+ // content-type. These tests drive the JSON surface directly to keep the
3579
+ // behavior locked.
3580
+
3581
+ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
3582
+ let h: Harness;
3583
+ beforeEach(() => {
3584
+ h = makeHarness();
3585
+ _resetOperationsRegistryForTests();
3586
+ });
3587
+ afterEach(() => h.cleanup());
3588
+
3589
+ test("GET /admin/setup returns JSON envelope when Accept: application/json", () => {
3590
+ const db = openHubDb(hubDbPath(h.dir));
3591
+ try {
3592
+ const deps = {
3593
+ db,
3594
+ manifestPath: h.manifestPath,
3595
+ configDir: h.dir,
3596
+ issuer: "http://127.0.0.1:1939",
3597
+ registry: getDefaultOperationsRegistry(),
3598
+ };
3599
+ const res = handleSetupGet(
3600
+ req("/admin/setup", { headers: { accept: "application/json" } }),
3601
+ deps,
3602
+ );
3603
+ expect(res.status).toBe(200);
3604
+ expect(res.headers.get("content-type")).toContain("application/json");
3605
+ } finally {
3606
+ db.close();
3607
+ }
3608
+ });
3609
+
3610
+ test("vault step skip mode short-circuits + persists setup_vault_skipped", async () => {
3611
+ const db = openHubDb(hubDbPath(h.dir));
3612
+ try {
3613
+ // Seed: admin exists so the wizard's vault step is reachable.
3614
+ await createUser(db, "owner", "pw");
3615
+ // Get a session cookie via a CSRF token GET first.
3616
+ const supervisor = makeSupervisor();
3617
+ const baseDeps = {
3618
+ db,
3619
+ manifestPath: h.manifestPath,
3620
+ configDir: h.dir,
3621
+ issuer: "http://127.0.0.1:1939",
3622
+ registry: getDefaultOperationsRegistry(),
3623
+ supervisor,
3624
+ };
3625
+ const getRes = handleSetupGet(
3626
+ req("/admin/setup", { headers: { accept: "application/json" } }),
3627
+ baseDeps,
3628
+ );
3629
+ const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
3630
+ const envelope = (await getRes.json()) as { csrfToken: string };
3631
+ // Build a session for the operator (proxy what an account POST
3632
+ // would do).
3633
+ const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import("../sessions.ts");
3634
+ const user = (await import("../users.ts")).getUserByUsername(db, "owner");
3635
+ if (!user) throw new Error("user missing");
3636
+ const session = createSession(db, { userId: user.id });
3637
+ const cookieHeader = `${SESSION_COOKIE_NAME}=${session.id}; ${CSRF_COOKIE_NAME}=${csrf}`;
3638
+ const postRes = await handleSetupVaultPost(
3639
+ req("/admin/setup/vault", {
3640
+ method: "POST",
3641
+ headers: {
3642
+ accept: "application/json",
3643
+ "content-type": "application/json",
3644
+ cookie: cookieHeader,
3645
+ },
3646
+ body: JSON.stringify({
3647
+ [CSRF_FIELD_NAME]: envelope.csrfToken,
3648
+ mode: "skip",
3649
+ }),
3650
+ }),
3651
+ baseDeps,
3652
+ );
3653
+ expect(postRes.status).toBe(200);
3654
+ expect(postRes.headers.get("content-type")).toContain("application/json");
3655
+ const body = (await postRes.json()) as { step: string };
3656
+ expect(body.step).toBe("expose");
3657
+ // The skip flag is persisted.
3658
+ expect(getSetting(db, "setup_vault_skipped")).toBe("true");
3659
+ // deriveWizardState advances past the vault step.
3660
+ const s = deriveWizardState({ db, manifestPath: h.manifestPath });
3661
+ expect(s.hasVault).toBe(true);
3662
+ expect(s.step).toBe("expose");
3663
+ } finally {
3664
+ db.close();
3665
+ }
3666
+ });
3667
+
3668
+ test("vault step import mode requires remote_url (400 on empty)", async () => {
3669
+ const db = openHubDb(hubDbPath(h.dir));
3670
+ try {
3671
+ await createUser(db, "owner", "pw");
3672
+ const supervisor = makeSupervisor();
3673
+ const baseDeps = {
3674
+ db,
3675
+ manifestPath: h.manifestPath,
3676
+ configDir: h.dir,
3677
+ issuer: "http://127.0.0.1:1939",
3678
+ registry: getDefaultOperationsRegistry(),
3679
+ supervisor,
3680
+ };
3681
+ const { createSession } = await import("../sessions.ts");
3682
+ const user = (await import("../users.ts")).getUserByUsername(db, "owner");
3683
+ if (!user) throw new Error("user missing");
3684
+ const session = createSession(db, { userId: user.id });
3685
+ // Need CSRF cookie value matching the body field. Pull a token
3686
+ // through a GET first.
3687
+ const getRes = handleSetupGet(
3688
+ req("/admin/setup", { headers: { accept: "application/json" } }),
3689
+ baseDeps,
3690
+ );
3691
+ const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
3692
+ const envelope = (await getRes.json()) as { csrfToken: string };
3693
+ const cookieHeader = `${SESSION_COOKIE_NAME}=${session.id}; ${CSRF_COOKIE_NAME}=${csrf}`;
3694
+ const postRes = await handleSetupVaultPost(
3695
+ req("/admin/setup/vault", {
3696
+ method: "POST",
3697
+ headers: {
3698
+ accept: "application/json",
3699
+ "content-type": "application/json",
3700
+ cookie: cookieHeader,
3701
+ },
3702
+ body: JSON.stringify({
3703
+ [CSRF_FIELD_NAME]: envelope.csrfToken,
3704
+ mode: "import",
3705
+ vault_name: "imported",
3706
+ remote_url: "",
3707
+ }),
3708
+ }),
3709
+ baseDeps,
3710
+ );
3711
+ expect(postRes.status).toBe(400);
3712
+ const body = (await postRes.json()) as { error: string; message: string };
3713
+ expect(body.error).toContain("Remote URL required");
3714
+ } finally {
3715
+ db.close();
3716
+ }
3717
+ });
3718
+
3719
+ // hub#168 fold (PR #447 reviewer): the import POST to vault MUST carry
3720
+ // a Bearer — vault's `authenticateVaultRequest` rejects 401 before
3721
+ // scope check on missing auth. Asserts the header is present, names
3722
+ // the vault, and the body shape is intact.
3723
+ test("postVaultImportImpl sends Authorization: Bearer + correct body to vault", async () => {
3724
+ let capturedUrl: string | undefined;
3725
+ let capturedHeaders: Headers | undefined;
3726
+ let capturedBody: unknown;
3727
+ const stubFetch = (async (input: string | URL | Request, init?: RequestInit) => {
3728
+ capturedUrl = typeof input === "string" ? input : input.toString();
3729
+ capturedHeaders = new Headers(init?.headers ?? {});
3730
+ capturedBody = JSON.parse((init?.body as string) ?? "{}");
3731
+ return new Response(
3732
+ JSON.stringify({
3733
+ notes_imported: 7,
3734
+ tags_imported: 2,
3735
+ attachments_imported: 0,
3736
+ warnings: [],
3737
+ }),
3738
+ { status: 200, headers: { "content-type": "application/json" } },
3739
+ );
3740
+ }) as typeof fetch;
3741
+
3742
+ const result = await postVaultImportImpl({
3743
+ vaultName: "imported",
3744
+ vaultPort: 1940,
3745
+ bearerToken: "stub-jwt-abc",
3746
+ remoteUrl: "https://github.com/owner/repo.git",
3747
+ mode: "merge",
3748
+ pat: "ghp_stub",
3749
+ fetcher: stubFetch,
3750
+ });
3751
+
3752
+ expect(result.notes_imported).toBe(7);
3753
+ expect(capturedUrl).toBe("http://127.0.0.1:1940/vault/imported/.parachute/mirror/import");
3754
+ expect(capturedHeaders?.get("authorization")).toBe("Bearer stub-jwt-abc");
3755
+ expect(capturedHeaders?.get("content-type")).toBe("application/json");
3756
+ expect(capturedBody).toEqual({
3757
+ remote_url: "https://github.com/owner/repo.git",
3758
+ mode: "merge",
3759
+ credentials: { kind: "pat", token: "ghp_stub" },
3760
+ });
3761
+ });
3762
+
3763
+ // No-PAT branch — public repo import. Sends `credentials: null`,
3764
+ // which vault interprets as "use stored credentials" (or none).
3765
+ // Reviewer-flagged coverage gap on the rc.8 fold.
3766
+ test("postVaultImportImpl sends credentials: null when no PAT is provided", async () => {
3767
+ let capturedBody: unknown;
3768
+ const stubFetch = (async (_: string | URL | Request, init?: RequestInit) => {
3769
+ capturedBody = JSON.parse((init?.body as string) ?? "{}");
3770
+ return new Response(
3771
+ JSON.stringify({ notes_imported: 1 }),
3772
+ { status: 200, headers: { "content-type": "application/json" } },
3773
+ );
3774
+ }) as typeof fetch;
3775
+
3776
+ await postVaultImportImpl({
3777
+ vaultName: "public-import",
3778
+ vaultPort: 1940,
3779
+ bearerToken: "stub",
3780
+ remoteUrl: "https://github.com/owner/public.git",
3781
+ mode: "replace",
3782
+ fetcher: stubFetch,
3783
+ });
3784
+
3785
+ expect(capturedBody).toEqual({
3786
+ remote_url: "https://github.com/owner/public.git",
3787
+ mode: "replace",
3788
+ credentials: null,
3789
+ });
3790
+ });
3791
+ });