@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 +1 -1
- package/src/__tests__/init.test.ts +33 -0
- package/src/__tests__/setup-wizard.test.ts +227 -2
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/cli.ts +33 -2
- package/src/commands/init.ts +171 -4
- package/src/commands/install.ts +33 -2
- package/src/commands/wizard.ts +843 -0
- package/src/help.ts +73 -1
- package/src/hub-settings.ts +11 -0
- package/src/setup-wizard.ts +629 -33
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|