@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -123,6 +123,43 @@ describe("cli per-subcommand help", () => {
|
|
|
123
123
|
expect(stderr).toMatch(/dash\.cloudflare\.com/);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test("expose cloudflare is an alias for expose public --cloudflare (Fix 5)", async () => {
|
|
127
|
+
// No --domain, non-TTY → same hard error as `expose public --cloudflare`.
|
|
128
|
+
// That it reaches the cloudflare-domain check (not "unknown layer") proves
|
|
129
|
+
// the alias rewrote the layer to public + forced the cloudflare flag.
|
|
130
|
+
const { code, stderr } = await runCli(["expose", "cloudflare"]);
|
|
131
|
+
expect(code).toBe(1);
|
|
132
|
+
expect(stderr).toMatch(/--domain <hostname> is required/);
|
|
133
|
+
expect(stderr).not.toMatch(/unknown layer/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("expose cloudflare --domain X routes to the cloudflare path (not 'unknown layer')", async () => {
|
|
137
|
+
// cloudflared isn't installed under PATH="", so the cloudflare path prints
|
|
138
|
+
// its own not-installed hint — distinct from the layer-validation error.
|
|
139
|
+
const proc = Bun.spawn(
|
|
140
|
+
[process.execPath, CLI, "expose", "cloudflare", "--domain", "vault.example.com"],
|
|
141
|
+
{
|
|
142
|
+
stdout: "pipe",
|
|
143
|
+
stderr: "pipe",
|
|
144
|
+
env: {
|
|
145
|
+
...process.env,
|
|
146
|
+
PATH: "",
|
|
147
|
+
HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
148
|
+
PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
153
|
+
new Response(proc.stdout).text(),
|
|
154
|
+
new Response(proc.stderr).text(),
|
|
155
|
+
proc.exited,
|
|
156
|
+
]);
|
|
157
|
+
expect(code).toBe(1);
|
|
158
|
+
expect(stderr).not.toMatch(/unknown layer/);
|
|
159
|
+
// Reached the cloudflare path (cloudflared detection), proving the alias.
|
|
160
|
+
expect(stdout).toMatch(/cloudflared is not installed/);
|
|
161
|
+
});
|
|
162
|
+
|
|
126
163
|
test("expose tailnet --cloudflare is rejected (cloudflare is public-only)", async () => {
|
|
127
164
|
const { code, stderr } = await runCli([
|
|
128
165
|
"expose",
|
|
@@ -217,11 +254,13 @@ describe("cli per-subcommand help", () => {
|
|
|
217
254
|
expect(stderr).toMatch(/parachute install vault/);
|
|
218
255
|
});
|
|
219
256
|
|
|
220
|
-
test("vault tokens create
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
257
|
+
test("vault tokens create forwards verbatim to parachute-vault", async () => {
|
|
258
|
+
// The guided interactive wrapper was removed with the pvt_* DROP (vault
|
|
259
|
+
// #412 / hub#466) — `vault tokens create` now always forwards transparently
|
|
260
|
+
// to parachute-vault (which exits 1 with migration guidance on a real box).
|
|
261
|
+
// Clearing PATH forces ENOENT — same probe as the `vault no-args` test.
|
|
262
|
+
// If we ever re-introduced a hub-side prompt, this subprocess would hang on
|
|
263
|
+
// stdin instead of exiting 127.
|
|
225
264
|
const proc = Bun.spawn([process.execPath, CLI, "vault", "tokens", "create"], {
|
|
226
265
|
stdout: "pipe",
|
|
227
266
|
stderr: "pipe",
|
|
@@ -59,10 +59,65 @@ describe("cloudflare detect", () => {
|
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
describe("cloudflaredInstallHint", () => {
|
|
63
|
+
test("darwin: names brew + points at GitHub releases as fallback", () => {
|
|
64
|
+
const hint = cloudflaredInstallHint("darwin", "arm64");
|
|
65
|
+
expect(hint).toContain("brew install cloudflared");
|
|
66
|
+
expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("linux x64: writes the curl line with the amd64 artifact suffix", () => {
|
|
70
|
+
// Refresh of stale URLs (2026-05-27). Aaron hit this on a fresh
|
|
71
|
+
// Amazon Linux 2023 install — `sudo dnf install cloudflared`
|
|
72
|
+
// returned 'No match for argument: cloudflared', and the hub's
|
|
73
|
+
// hint pointed at developers.cloudflare.com paths that 404. The
|
|
74
|
+
// GitHub release is the reliable cross-distro path.
|
|
75
|
+
const hint = cloudflaredInstallHint("linux", "x64");
|
|
76
|
+
expect(hint).toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
77
|
+
expect(hint).toContain(
|
|
78
|
+
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
|
79
|
+
);
|
|
80
|
+
expect(hint).toContain("sudo chmod +x /usr/local/bin/cloudflared");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("linux arm64: writes the arm64 artifact suffix", () => {
|
|
84
|
+
const hint = cloudflaredInstallHint("linux", "arm64");
|
|
85
|
+
expect(hint).toContain("cloudflared-linux-arm64");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("linux arm (32-bit): writes the arm artifact suffix", () => {
|
|
89
|
+
const hint = cloudflaredInstallHint("linux", "arm");
|
|
90
|
+
expect(hint).toContain("cloudflared-linux-arm");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("linux exotic arch: falls back to a generic GitHub releases pointer", () => {
|
|
94
|
+
// riscv64 / ppc64 / mips64 — no cloudflared artifact published, so
|
|
95
|
+
// we don't fabricate a 404-bound download URL; we point the user at
|
|
96
|
+
// the releases page and surface what their arch is so they can pick.
|
|
97
|
+
const hint = cloudflaredInstallHint("linux", "riscv64");
|
|
98
|
+
expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
|
|
99
|
+
expect(hint).toContain("riscv64");
|
|
100
|
+
expect(hint).not.toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("no stale developers.cloudflare.com or pkg.cloudflare.com paths anywhere", () => {
|
|
104
|
+
// Aaron caught both URL shapes returning HTML/404 on 2026-05-27 —
|
|
105
|
+
// they had been the hub's installer instructions for months.
|
|
106
|
+
// Hard-assert they're gone so they don't regress.
|
|
107
|
+
for (const platform of ["darwin", "linux"] as const) {
|
|
108
|
+
for (const arch of ["x64", "arm64"] as const) {
|
|
109
|
+
const hint = cloudflaredInstallHint(platform, arch);
|
|
110
|
+
expect(hint).not.toContain("developers.cloudflare.com");
|
|
111
|
+
expect(hint).not.toContain("pkg.cloudflare.com");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("non-Linux, non-darwin platform: GitHub releases pointer with no curl line", () => {
|
|
117
|
+
const hint = cloudflaredInstallHint("win32", "x64");
|
|
118
|
+
expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
|
|
119
|
+
expect(hint).not.toContain("brew install");
|
|
120
|
+
expect(hint).not.toContain("curl -L -o");
|
|
121
|
+
});
|
|
67
122
|
});
|
|
68
123
|
});
|
|
@@ -24,46 +24,53 @@ describe("is2FAEnrolled", () => {
|
|
|
24
24
|
expect(is2FAEnrolled({ status: status({ hasTotp: false }) })).toBe(false);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("
|
|
27
|
+
test("falls back to legacy config.yaml totp_secret when hub.db is absent", () => {
|
|
28
|
+
// hub#473: hub.db is the source of truth for real 2FA, but a super-old
|
|
29
|
+
// install with no hub.db still suppresses the warning if the legacy vault
|
|
30
|
+
// YAML totp_secret is set. Point hubDbPath at a nonexistent file so the
|
|
31
|
+
// YAML fallback is exercised.
|
|
28
32
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
29
33
|
try {
|
|
30
34
|
writeFileSync(join(dir, "config.yaml"), 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
|
|
31
|
-
expect(is2FAEnrolled({ vaultHome: dir })).toBe(true);
|
|
35
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(true);
|
|
32
36
|
} finally {
|
|
33
37
|
rmSync(dir, { recursive: true, force: true });
|
|
34
38
|
}
|
|
35
39
|
});
|
|
36
40
|
|
|
37
|
-
test("missing config.yaml → not enrolled (false)", () => {
|
|
41
|
+
test("missing config.yaml + absent hub.db → not enrolled (false)", () => {
|
|
38
42
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
39
43
|
try {
|
|
40
|
-
// No config.yaml written.
|
|
41
|
-
expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
|
|
44
|
+
// No config.yaml written, no hub.db.
|
|
45
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
|
|
42
46
|
} finally {
|
|
43
47
|
rmSync(dir, { recursive: true, force: true });
|
|
44
48
|
}
|
|
45
49
|
});
|
|
46
50
|
|
|
47
|
-
test("empty totp_secret value → not enrolled
|
|
51
|
+
test("empty totp_secret value + absent hub.db → not enrolled", () => {
|
|
48
52
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
49
53
|
try {
|
|
50
54
|
writeFileSync(join(dir, "config.yaml"), 'totp_secret: ""\n');
|
|
51
|
-
expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
|
|
55
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
|
|
52
56
|
} finally {
|
|
53
57
|
rmSync(dir, { recursive: true, force: true });
|
|
54
58
|
}
|
|
55
59
|
});
|
|
56
60
|
|
|
57
|
-
test("
|
|
58
|
-
// The probe parses config.yaml with a line-anchored regex (no YAML
|
|
59
|
-
// dependency), so junk content simply doesn't match `totp_secret: "..."`
|
|
60
|
-
// and resolves to `hasTotp: false` — which fires the public-exposure
|
|
61
|
-
// warning rather than silently suppressing it. Pin that contract so a
|
|
62
|
-
// future refactor of auth-status.ts can't quietly invert it.
|
|
61
|
+
test("hub.db with an enrolled user → enrolled (the real signal)", async () => {
|
|
63
62
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
64
63
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const { hubDbPath, openHubDb } = await import("../hub-db.ts");
|
|
65
|
+
const { createUser } = await import("../users.ts");
|
|
66
|
+
const { persistEnrollment } = await import("../two-factor-store.ts");
|
|
67
|
+
const { generateTotpSecret } = await import("../totp.ts");
|
|
68
|
+
const dbPath = hubDbPath(dir);
|
|
69
|
+
const db = openHubDb(dbPath);
|
|
70
|
+
const u = await createUser(db, "owner", "owner-password-123");
|
|
71
|
+
await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
|
|
72
|
+
db.close();
|
|
73
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: dbPath })).toBe(true);
|
|
67
74
|
} finally {
|
|
68
75
|
rmSync(dir, { recursive: true, force: true });
|
|
69
76
|
}
|
|
@@ -80,9 +87,14 @@ describe("printPublic2FAWarning", () => {
|
|
|
80
87
|
});
|
|
81
88
|
expect(fired).toBe(true);
|
|
82
89
|
const joined = logs.join("\n");
|
|
83
|
-
|
|
90
|
+
// hub#473: real hub-login 2FA. The warning now recommends the real
|
|
91
|
+
// `parachute auth 2fa enroll` path (+ the /account/2fa browser path) and
|
|
92
|
+
// still nudges a strong owner password.
|
|
93
|
+
expect(joined).toContain("/login is now reachable on the public internet");
|
|
84
94
|
expect(joined).toContain("https://vault.example.com/login");
|
|
85
95
|
expect(joined).toContain("parachute auth 2fa enroll");
|
|
96
|
+
expect(joined).toContain("/account/2fa");
|
|
97
|
+
expect(joined).toContain("parachute auth set-password");
|
|
86
98
|
});
|
|
87
99
|
|
|
88
100
|
test("enrolled → suppressed, returns false, logs nothing", () => {
|
|
@@ -108,7 +120,9 @@ describe("printPublic2FAWarning", () => {
|
|
|
108
120
|
publicUrl: "https://vault.example.com",
|
|
109
121
|
});
|
|
110
122
|
expect(fired).toBe(true);
|
|
111
|
-
expect(logs.some((l) => l.includes("
|
|
123
|
+
expect(logs.some((l) => l.includes("/login is now reachable on the public internet"))).toBe(
|
|
124
|
+
true,
|
|
125
|
+
);
|
|
112
126
|
});
|
|
113
127
|
|
|
114
128
|
test("embeds the supplied publicUrl into the /login pointer", () => {
|
|
@@ -40,128 +40,124 @@ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
describe("runAuthPreflight — wide open (no password
|
|
44
|
-
test("warns loudly
|
|
45
|
-
const h = makeHarness(["y", "
|
|
43
|
+
describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
44
|
+
test("warns loudly, offers password + real 2FA enroll, prints hub-JWT token guidance", async () => {
|
|
45
|
+
const h = makeHarness(["y", "y"]); // password yes, 2FA yes
|
|
46
46
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
47
47
|
const joined = h.logs.join("\n");
|
|
48
|
-
expect(joined).toContain("No owner password
|
|
48
|
+
expect(joined).toContain("No owner password");
|
|
49
49
|
expect(joined).toContain("public internet");
|
|
50
|
-
|
|
51
|
-
expect(
|
|
50
|
+
// Programmatic-client guidance points at the hub mint path, not pvt_*.
|
|
51
|
+
expect(joined).toContain("parachute auth mint-token --scope vault:default:read");
|
|
52
|
+
expect(joined).toContain("Bearer <hub-jwt>");
|
|
53
|
+
// Real 2FA (hub#473) — both commands offered + run.
|
|
54
|
+
expect(h.commands.map((c) => c.join(" "))).toEqual([
|
|
55
|
+
"parachute auth set-password",
|
|
56
|
+
"parachute auth 2fa enroll",
|
|
57
|
+
]);
|
|
58
|
+
// Two interactive offers now: password + 2FA. Token guidance is printed.
|
|
59
|
+
expect(h.prompts).toHaveLength(2);
|
|
52
60
|
});
|
|
53
61
|
|
|
54
|
-
test("
|
|
55
|
-
const h = makeHarness(["", ""
|
|
62
|
+
test("token guidance uses the first discovered vault name", async () => {
|
|
63
|
+
const h = makeHarness(["n", "n"]);
|
|
64
|
+
await runAuthPreflight({ status: status({ vaultNames: ["work"] }), ...wire(h) });
|
|
65
|
+
expect(h.logs.join("\n")).toContain("--scope vault:work:read");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("user declines both prompts → no subprocesses run", async () => {
|
|
69
|
+
const h = makeHarness(["", ""]); // Enter = skip both
|
|
56
70
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
57
71
|
expect(h.commands).toHaveLength(0);
|
|
58
|
-
|
|
59
|
-
expect(h.prompts).toHaveLength(3);
|
|
72
|
+
expect(h.prompts).toHaveLength(2);
|
|
60
73
|
});
|
|
61
74
|
|
|
62
|
-
test("user accepts
|
|
63
|
-
const h = makeHarness(["y", "
|
|
75
|
+
test("user accepts the password offer → set-password invoked", async () => {
|
|
76
|
+
const h = makeHarness(["y", "n"]);
|
|
64
77
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
65
|
-
expect(h.commands.map((c) => c.join(" "))).toEqual([
|
|
66
|
-
"parachute auth set-password",
|
|
67
|
-
"parachute auth 2fa enroll",
|
|
68
|
-
"parachute vault tokens create",
|
|
69
|
-
]);
|
|
78
|
+
expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth set-password"]);
|
|
70
79
|
});
|
|
71
|
-
});
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
test("null tokenCount with no owner password still classifies wide-open", async () => {
|
|
82
|
+
// The `tokenCount: null` (unreadable vault DB) path is vestigial post-DROP
|
|
83
|
+
// — `classify()` gates on `hasOwnerPassword` alone.
|
|
84
|
+
const h = makeHarness(["n", "n"]);
|
|
76
85
|
await runAuthPreflight({
|
|
77
|
-
status: status({ hasOwnerPassword:
|
|
86
|
+
status: status({ hasOwnerPassword: false, tokenCount: null }),
|
|
78
87
|
...wire(h),
|
|
79
88
|
});
|
|
80
89
|
const joined = h.logs.join("\n");
|
|
81
|
-
expect(joined).toContain("
|
|
82
|
-
expect(joined).toContain("
|
|
83
|
-
expect(h.prompts).toHaveLength(
|
|
84
|
-
expect(h.commands).toEqual([["parachute", "auth", "2fa", "enroll"]]);
|
|
90
|
+
expect(joined).toContain("No owner password");
|
|
91
|
+
expect(joined).toContain("public internet");
|
|
92
|
+
expect(h.prompts).toHaveLength(2);
|
|
85
93
|
});
|
|
86
94
|
|
|
87
|
-
test("
|
|
88
|
-
const h = makeHarness([""]);
|
|
89
|
-
await runAuthPreflight({
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
expect(
|
|
95
|
+
test("never offers the dead vault-tokens-create command", async () => {
|
|
96
|
+
const h = makeHarness(["y", "n"]);
|
|
97
|
+
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
98
|
+
const allCommands = h.commands.map((c) => c.join(" ")).join("\n");
|
|
99
|
+
expect(allCommands).not.toContain("vault tokens create");
|
|
100
|
+
const guidance = h.logs.join("\n");
|
|
101
|
+
expect(guidance).not.toContain("parachute vault tokens create");
|
|
94
102
|
});
|
|
95
103
|
});
|
|
96
104
|
|
|
97
|
-
describe("runAuthPreflight —
|
|
98
|
-
test("
|
|
99
|
-
const h = makeHarness(["
|
|
105
|
+
describe("runAuthPreflight — password set, no 2FA", () => {
|
|
106
|
+
test("confirms password set + offers real 2FA enroll (ignores vestigial tokenCount)", async () => {
|
|
107
|
+
const h = makeHarness(["n"]); // decline 2FA
|
|
100
108
|
await runAuthPreflight({
|
|
101
|
-
|
|
109
|
+
// tokenCount is non-zero (vestigial pvt_* rows) but no longer consulted.
|
|
110
|
+
status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: 3 }),
|
|
102
111
|
...wire(h),
|
|
103
112
|
});
|
|
104
113
|
const joined = h.logs.join("\n");
|
|
105
|
-
expect(joined).toContain("
|
|
106
|
-
|
|
114
|
+
expect(joined).toContain("Owner password is set");
|
|
115
|
+
// Real 2FA offer (hub#473) — exactly one prompt (the 2FA offer).
|
|
107
116
|
expect(h.prompts).toHaveLength(1);
|
|
108
|
-
expect(h.commands).
|
|
117
|
+
expect(h.commands).toHaveLength(0); // declined
|
|
109
118
|
});
|
|
110
|
-
});
|
|
111
119
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const h = makeHarness([]);
|
|
120
|
+
test("accepting the 2FA offer runs `parachute auth 2fa enroll`", async () => {
|
|
121
|
+
const h = makeHarness(["y"]);
|
|
115
122
|
await runAuthPreflight({
|
|
116
|
-
status: status({ hasOwnerPassword:
|
|
123
|
+
status: status({ hasOwnerPassword: true, hasTotp: false }),
|
|
117
124
|
...wire(h),
|
|
118
125
|
});
|
|
119
|
-
|
|
120
|
-
expect(joined).toContain("Couldn't read vault token state");
|
|
121
|
-
expect(joined).toContain("parachute vault tokens list");
|
|
122
|
-
// No prompts because we don't offer password/token flow when token
|
|
123
|
-
// state is unknown (it'd be ambiguous whether we're dealing with the
|
|
124
|
-
// wide-open or the tokens-only case).
|
|
125
|
-
expect(h.prompts).toHaveLength(0);
|
|
126
|
+
expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth 2fa enroll"]);
|
|
126
127
|
});
|
|
127
128
|
|
|
128
|
-
test("
|
|
129
|
-
const h = makeHarness([""]);
|
|
129
|
+
test("null tokenCount (DB unreadable) is irrelevant — password gates the branch", async () => {
|
|
130
|
+
const h = makeHarness(["n"]);
|
|
130
131
|
await runAuthPreflight({
|
|
131
132
|
status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
|
|
132
133
|
...wire(h),
|
|
133
134
|
});
|
|
135
|
+
expect(h.logs.join("\n")).toContain("Owner password is set");
|
|
134
136
|
expect(h.prompts).toHaveLength(1);
|
|
135
|
-
expect(h.prompts[0]?.toLowerCase()).toContain("2fa");
|
|
136
137
|
});
|
|
137
138
|
});
|
|
138
139
|
|
|
139
|
-
describe("runAuthPreflight —
|
|
140
|
-
test("
|
|
140
|
+
describe("runAuthPreflight — password + 2FA both set", () => {
|
|
141
|
+
test("two-line confirmation, no prompts", async () => {
|
|
141
142
|
const h = makeHarness([]);
|
|
142
143
|
await runAuthPreflight({
|
|
143
|
-
status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount:
|
|
144
|
+
status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 0 }),
|
|
144
145
|
...wire(h),
|
|
145
146
|
});
|
|
146
147
|
const joined = h.logs.join("\n");
|
|
147
|
-
expect(joined).toContain("
|
|
148
|
+
expect(joined).toContain("Owner password is set");
|
|
149
|
+
expect(joined).toContain("Two-factor authentication is on");
|
|
148
150
|
expect(h.prompts).toHaveLength(0);
|
|
149
151
|
expect(h.commands).toHaveLength(0);
|
|
150
152
|
});
|
|
151
153
|
});
|
|
152
154
|
|
|
153
155
|
describe("runAuthPreflight — subprocess failure handling", () => {
|
|
154
|
-
test("non-zero exit from
|
|
155
|
-
const h = makeHarness(["y", "
|
|
156
|
-
// Override the interactive runner to return non-zero on the first call.
|
|
157
|
-
let first = true;
|
|
156
|
+
test("non-zero exit from set-password doesn't abort the rest of the preflight", async () => {
|
|
157
|
+
const h = makeHarness(["y", "n"]);
|
|
158
158
|
const interactiveRunner = async (cmd: readonly string[]) => {
|
|
159
159
|
h.commands.push([...cmd]);
|
|
160
|
-
|
|
161
|
-
first = false;
|
|
162
|
-
return 7;
|
|
163
|
-
}
|
|
164
|
-
return 0;
|
|
160
|
+
return 7;
|
|
165
161
|
};
|
|
166
162
|
await runAuthPreflight({
|
|
167
163
|
status: status(),
|
|
@@ -172,19 +168,22 @@ describe("runAuthPreflight — subprocess failure handling", () => {
|
|
|
172
168
|
},
|
|
173
169
|
interactiveRunner,
|
|
174
170
|
});
|
|
175
|
-
//
|
|
176
|
-
|
|
171
|
+
// The command was attempted; the flow continued (token guidance still
|
|
172
|
+
// printed afterward).
|
|
173
|
+
expect(h.commands.map((c) => c[0])).toEqual(["parachute"]);
|
|
177
174
|
const joined = h.logs.join("\n");
|
|
178
175
|
expect(joined).toContain("exited 7");
|
|
176
|
+
expect(joined).toContain("Bearer <hub-jwt>");
|
|
179
177
|
});
|
|
180
178
|
});
|
|
181
179
|
|
|
182
180
|
describe("runAuthPreflight — case-insensitive yes", () => {
|
|
183
181
|
test('"Y", "YES", and "y" all count as affirmative; anything else is decline', async () => {
|
|
182
|
+
// Drive the password-set-no-2FA path so there's exactly one prompt (2FA).
|
|
184
183
|
for (const yes of ["y", "Y", "yes", "YES"]) {
|
|
185
184
|
const h = makeHarness([yes]);
|
|
186
185
|
await runAuthPreflight({
|
|
187
|
-
status: status({ hasOwnerPassword: true,
|
|
186
|
+
status: status({ hasOwnerPassword: true, hasTotp: false }),
|
|
188
187
|
...wire(h),
|
|
189
188
|
});
|
|
190
189
|
expect(h.commands).toHaveLength(1);
|
|
@@ -192,7 +191,7 @@ describe("runAuthPreflight — case-insensitive yes", () => {
|
|
|
192
191
|
for (const no of ["", "n", "no", "q", "bogus"]) {
|
|
193
192
|
const h = makeHarness([no]);
|
|
194
193
|
await runAuthPreflight({
|
|
195
|
-
status: status({ hasOwnerPassword: true,
|
|
194
|
+
status: status({ hasOwnerPassword: true, hasTotp: false }),
|
|
196
195
|
...wire(h),
|
|
197
196
|
});
|
|
198
197
|
expect(h.commands).toHaveLength(0);
|