@openparachute/hub 0.5.14-rc.10 → 0.5.14-rc.12
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__/auth.test.ts +25 -32
- package/src/__tests__/cli.test.ts +37 -0
- package/src/__tests__/expose-2fa-warning.test.ts +9 -3
- package/src/__tests__/expose-auth-preflight.test.ts +48 -59
- package/src/__tests__/expose-cloudflare.test.ts +128 -9
- package/src/__tests__/expose.test.ts +5 -2
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/cli.ts +10 -1
- package/src/commands/auth.ts +50 -25
- package/src/commands/expose-2fa-warning.ts +26 -26
- package/src/commands/expose-auth-preflight.ts +31 -35
- package/src/commands/expose-cloudflare.ts +59 -2
- package/src/commands/init.ts +31 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/help.ts +3 -1
- package/src/setup-wizard.ts +5 -1
package/package.json
CHANGED
|
@@ -65,34 +65,26 @@ async function captureOutput(fn: () => Promise<number> | number): Promise<{
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
describe("parachute auth", () => {
|
|
68
|
-
test("2fa
|
|
68
|
+
test("2fa is an honest hub-side stub — never forwards to parachute-vault", async () => {
|
|
69
|
+
// 2fa used to forward to the deprecated `parachute-vault 2fa` stub, which
|
|
70
|
+
// exits 1 post auth-unification and only wrote vault YAML that never gated
|
|
71
|
+
// hub /login. It's now an informational stub: exit 0, no subprocess.
|
|
69
72
|
const { runner, calls } = makeRunner(0);
|
|
70
|
-
const
|
|
71
|
-
expect(code).toBe(0);
|
|
72
|
-
expect(calls).toEqual([
|
|
73
|
+
const out = await captureOutput(() => auth(["2fa", "enroll"], runner));
|
|
74
|
+
expect(out.code).toBe(0);
|
|
75
|
+
expect(calls).toEqual([]); // did NOT spawn parachute-vault
|
|
76
|
+
expect(out.stdout).toContain("isn't available yet");
|
|
77
|
+
expect(out.stdout).toContain("#473");
|
|
78
|
+
// Doesn't tell the operator to run the dead vault command.
|
|
79
|
+
expect(out.stdout).not.toContain("2fa enroll");
|
|
73
80
|
});
|
|
74
81
|
|
|
75
|
-
test("2fa
|
|
82
|
+
test("2fa with any sub-args still resolves to the honest stub (exit 0, no spawn)", async () => {
|
|
76
83
|
const { runner, calls } = makeRunner(0);
|
|
77
|
-
const
|
|
78
|
-
expect(code).toBe(0);
|
|
79
|
-
expect(calls).toEqual([
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
test("exit code from parachute-vault is propagated", async () => {
|
|
83
|
-
const { runner } = makeRunner(3);
|
|
84
|
-
const code = await auth(["2fa", "status"], runner);
|
|
85
|
-
expect(code).toBe(3);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("ENOENT on a vault-forwarded subcommand surfaces install hint and exit 127", async () => {
|
|
89
|
-
const runner: Runner = {
|
|
90
|
-
async run() {
|
|
91
|
-
throw new Error("ENOENT: spawn parachute-vault");
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
const code = await auth(["2fa", "status"], runner);
|
|
95
|
-
expect(code).toBe(127);
|
|
84
|
+
const out = await captureOutput(() => auth(["2fa", "status", "--whatever"], runner));
|
|
85
|
+
expect(out.code).toBe(0);
|
|
86
|
+
expect(calls).toEqual([]);
|
|
87
|
+
expect(out.stdout).toContain("#473");
|
|
96
88
|
});
|
|
97
89
|
|
|
98
90
|
test("set-password no longer forwards to vault", async () => {
|
|
@@ -142,23 +134,24 @@ describe("authHelp", () => {
|
|
|
142
134
|
test("lists every blessed subcommand", () => {
|
|
143
135
|
expect(h).toContain("parachute auth set-password");
|
|
144
136
|
expect(h).toContain("parachute auth list-users");
|
|
145
|
-
expect(h).toContain("parachute auth 2fa
|
|
146
|
-
expect(h).toContain("parachute auth 2fa enroll");
|
|
147
|
-
expect(h).toContain("parachute auth 2fa disable");
|
|
148
|
-
expect(h).toContain("parachute auth 2fa backup-codes");
|
|
137
|
+
expect(h).toContain("parachute auth 2fa");
|
|
149
138
|
expect(h).toContain("parachute auth rotate-key");
|
|
150
139
|
});
|
|
151
140
|
|
|
141
|
+
test("2fa help is honest about hub-login TOTP not being shipped (#473)", () => {
|
|
142
|
+
expect(h).toContain("#473");
|
|
143
|
+
// No longer advertises the dead enroll/disable/backup-codes subcommands.
|
|
144
|
+
expect(h).not.toContain("2fa enroll");
|
|
145
|
+
expect(h).not.toContain("2fa disable");
|
|
146
|
+
expect(h).not.toContain("2fa backup-codes");
|
|
147
|
+
});
|
|
148
|
+
|
|
152
149
|
test("set-password help mentions the new flags + hub-local home", () => {
|
|
153
150
|
expect(h).toContain("--username");
|
|
154
151
|
expect(h).toContain("--allow-multi");
|
|
155
152
|
expect(h).toContain("hub.db");
|
|
156
153
|
});
|
|
157
154
|
|
|
158
|
-
test("mentions the vault-install hint", () => {
|
|
159
|
-
expect(h).toContain("parachute install vault");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
155
|
test("rotate-key explains the 24h JWKS retention", () => {
|
|
163
156
|
expect(h).toContain("jwks.json");
|
|
164
157
|
// "24" + "hours" may be split by line wrap; check both pieces.
|
|
@@ -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",
|
|
@@ -80,9 +80,13 @@ describe("printPublic2FAWarning", () => {
|
|
|
80
80
|
});
|
|
81
81
|
expect(fired).toBe(true);
|
|
82
82
|
const joined = logs.join("\n");
|
|
83
|
-
|
|
83
|
+
// Honest copy: /login is public, owner password is the wall, hub-login 2FA
|
|
84
|
+
// is coming (#473) — does NOT recommend the dead `auth 2fa enroll` path.
|
|
85
|
+
expect(joined).toContain("/login is now reachable on the public internet");
|
|
84
86
|
expect(joined).toContain("https://vault.example.com/login");
|
|
85
|
-
expect(joined).toContain("
|
|
87
|
+
expect(joined).toContain("#473");
|
|
88
|
+
expect(joined).toContain("parachute auth set-password");
|
|
89
|
+
expect(joined).not.toContain("parachute auth 2fa enroll");
|
|
86
90
|
});
|
|
87
91
|
|
|
88
92
|
test("enrolled → suppressed, returns false, logs nothing", () => {
|
|
@@ -108,7 +112,9 @@ describe("printPublic2FAWarning", () => {
|
|
|
108
112
|
publicUrl: "https://vault.example.com",
|
|
109
113
|
});
|
|
110
114
|
expect(fired).toBe(true);
|
|
111
|
-
expect(logs.some((l) => l.includes("
|
|
115
|
+
expect(logs.some((l) => l.includes("/login is now reachable on the public internet"))).toBe(
|
|
116
|
+
true,
|
|
117
|
+
);
|
|
112
118
|
});
|
|
113
119
|
|
|
114
120
|
test("embeds the supplied publicUrl into the /login pointer", () => {
|
|
@@ -41,8 +41,8 @@ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
44
|
-
test("warns loudly, offers password
|
|
45
|
-
const h = makeHarness(["y"
|
|
44
|
+
test("warns loudly, offers password only, prints hub-JWT token guidance + honest 2FA note", async () => {
|
|
45
|
+
const h = makeHarness(["y"]); // password yes
|
|
46
46
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
47
47
|
const joined = h.logs.join("\n");
|
|
48
48
|
expect(joined).toContain("No owner password");
|
|
@@ -50,33 +50,34 @@ describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
|
50
50
|
// Programmatic-client guidance points at the hub mint path, not pvt_*.
|
|
51
51
|
expect(joined).toContain("parachute auth mint-token --scope vault:default:read");
|
|
52
52
|
expect(joined).toContain("Bearer <hub-jwt>");
|
|
53
|
-
//
|
|
54
|
-
|
|
53
|
+
// Honest 2FA state — coming (#473), not offered as a dead enroll command.
|
|
54
|
+
expect(joined).toContain("#473");
|
|
55
|
+
expect(joined).not.toContain("2fa enroll");
|
|
56
|
+
// Only password is an interactive offer; token guidance + 2FA note are
|
|
57
|
+
// printed, not prompted.
|
|
55
58
|
expect(h.commands).toHaveLength(1);
|
|
56
59
|
expect(h.commands[0]).toEqual(["parachute", "auth", "set-password"]);
|
|
60
|
+
expect(h.prompts).toHaveLength(1);
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
test("token guidance uses the first discovered vault name", async () => {
|
|
60
|
-
const h = makeHarness(["n"
|
|
64
|
+
const h = makeHarness(["n"]);
|
|
61
65
|
await runAuthPreflight({ status: status({ vaultNames: ["work"] }), ...wire(h) });
|
|
62
66
|
expect(h.logs.join("\n")).toContain("--scope vault:work:read");
|
|
63
67
|
});
|
|
64
68
|
|
|
65
|
-
test("user declines
|
|
66
|
-
const h = makeHarness([""
|
|
69
|
+
test("user declines the password prompt → no subprocesses run", async () => {
|
|
70
|
+
const h = makeHarness([""]); // Enter = skip
|
|
67
71
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
68
72
|
expect(h.commands).toHaveLength(0);
|
|
69
|
-
//
|
|
70
|
-
expect(h.prompts).toHaveLength(
|
|
73
|
+
// Only one prompt now (password); token guidance + 2FA note aren't prompts.
|
|
74
|
+
expect(h.prompts).toHaveLength(1);
|
|
71
75
|
});
|
|
72
76
|
|
|
73
|
-
test("user accepts password
|
|
74
|
-
const h = makeHarness(["y"
|
|
77
|
+
test("user accepts the password offer → set-password invoked", async () => {
|
|
78
|
+
const h = makeHarness(["y"]);
|
|
75
79
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
76
|
-
expect(h.commands.map((c) => c.join(" "))).toEqual([
|
|
77
|
-
"parachute auth set-password",
|
|
78
|
-
"parachute auth 2fa enroll",
|
|
79
|
-
]);
|
|
80
|
+
expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth set-password"]);
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
test("null tokenCount with no owner password still classifies wide-open", async () => {
|
|
@@ -84,7 +85,7 @@ describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
|
84
85
|
// — `classify()` gates on `hasOwnerPassword` alone. A box with no owner
|
|
85
86
|
// password AND an unreadable token DB must still take the loud wide-open
|
|
86
87
|
// branch, not silently fall through to a quieter state.
|
|
87
|
-
const h = makeHarness(["n"
|
|
88
|
+
const h = makeHarness(["n"]);
|
|
88
89
|
await runAuthPreflight({
|
|
89
90
|
status: status({ hasOwnerPassword: false, tokenCount: null }),
|
|
90
91
|
...wire(h),
|
|
@@ -92,24 +93,26 @@ describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
|
92
93
|
const joined = h.logs.join("\n");
|
|
93
94
|
expect(joined).toContain("No owner password");
|
|
94
95
|
expect(joined).toContain("public internet");
|
|
95
|
-
// Wide-open offers password
|
|
96
|
-
expect(h.prompts).toHaveLength(
|
|
96
|
+
// Wide-open offers the password (one prompt); not the password-set path.
|
|
97
|
+
expect(h.prompts).toHaveLength(1);
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
test("never offers
|
|
100
|
-
const h = makeHarness(["y"
|
|
100
|
+
test("never offers a dead command (vault tokens create OR auth 2fa enroll)", async () => {
|
|
101
|
+
const h = makeHarness(["y"]);
|
|
101
102
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
102
103
|
const allCommands = h.commands.map((c) => c.join(" ")).join("\n");
|
|
103
104
|
expect(allCommands).not.toContain("vault tokens create");
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
expect(allCommands).not.toContain("auth 2fa enroll");
|
|
106
|
+
// And no log line steers the operator at a dead command as guidance.
|
|
107
|
+
const guidance = h.logs.join("\n");
|
|
106
108
|
expect(guidance).not.toContain("parachute vault tokens create");
|
|
109
|
+
expect(guidance).not.toContain("parachute auth 2fa enroll");
|
|
107
110
|
});
|
|
108
111
|
});
|
|
109
112
|
|
|
110
|
-
describe("runAuthPreflight — password set
|
|
111
|
-
test("
|
|
112
|
-
const h = makeHarness([
|
|
113
|
+
describe("runAuthPreflight — password set", () => {
|
|
114
|
+
test("single confirmation line + honest 2FA note, no prompts (ignores vestigial tokenCount)", async () => {
|
|
115
|
+
const h = makeHarness([]);
|
|
113
116
|
await runAuthPreflight({
|
|
114
117
|
// tokenCount is non-zero (vestigial pvt_* rows) but no longer consulted.
|
|
115
118
|
status: status({ hasOwnerPassword: true, tokenCount: 3 }),
|
|
@@ -117,59 +120,42 @@ describe("runAuthPreflight — password set, no 2FA", () => {
|
|
|
117
120
|
});
|
|
118
121
|
const joined = h.logs.join("\n");
|
|
119
122
|
expect(joined).toContain("Owner password is set");
|
|
120
|
-
|
|
121
|
-
expect(
|
|
122
|
-
expect(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
test("user declines → no command runs", async () => {
|
|
126
|
-
const h = makeHarness([""]);
|
|
127
|
-
await runAuthPreflight({
|
|
128
|
-
status: status({ hasOwnerPassword: true, tokenCount: 3 }),
|
|
129
|
-
...wire(h),
|
|
130
|
-
});
|
|
123
|
+
// Honest 2FA note (#473) — not a prompt, not the dead enroll command.
|
|
124
|
+
expect(joined).toContain("#473");
|
|
125
|
+
expect(joined).not.toContain("2fa enroll");
|
|
126
|
+
expect(h.prompts).toHaveLength(0);
|
|
131
127
|
expect(h.commands).toHaveLength(0);
|
|
132
128
|
});
|
|
133
129
|
|
|
134
130
|
test("null tokenCount (DB unreadable) is irrelevant — password gates the branch", async () => {
|
|
135
|
-
const h = makeHarness([
|
|
131
|
+
const h = makeHarness([]);
|
|
136
132
|
await runAuthPreflight({
|
|
137
133
|
status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
|
|
138
134
|
...wire(h),
|
|
139
135
|
});
|
|
140
|
-
expect(h.
|
|
141
|
-
expect(h.prompts
|
|
136
|
+
expect(h.logs.join("\n")).toContain("Owner password is set");
|
|
137
|
+
expect(h.prompts).toHaveLength(0);
|
|
138
|
+
expect(h.commands).toHaveLength(0);
|
|
142
139
|
});
|
|
143
|
-
});
|
|
144
140
|
|
|
145
|
-
|
|
146
|
-
test("single positive line, no prompts (tokens not required)", async () => {
|
|
141
|
+
test("password + legacy vault TOTP — still the quiet password-set path", async () => {
|
|
147
142
|
const h = makeHarness([]);
|
|
148
143
|
await runAuthPreflight({
|
|
149
|
-
// tokenCount: 0 — a hub JWT is minted on demand, not a standing need.
|
|
150
144
|
status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 0 }),
|
|
151
145
|
...wire(h),
|
|
152
146
|
});
|
|
153
|
-
|
|
154
|
-
expect(joined).toContain("looks good");
|
|
155
|
-
expect(joined).toContain("owner password + 2FA");
|
|
147
|
+
expect(h.logs.join("\n")).toContain("Owner password is set");
|
|
156
148
|
expect(h.prompts).toHaveLength(0);
|
|
157
149
|
expect(h.commands).toHaveLength(0);
|
|
158
150
|
});
|
|
159
151
|
});
|
|
160
152
|
|
|
161
153
|
describe("runAuthPreflight — subprocess failure handling", () => {
|
|
162
|
-
test("non-zero exit from
|
|
163
|
-
const h = makeHarness(["y"
|
|
164
|
-
// Override the interactive runner to return non-zero on the first call.
|
|
165
|
-
let first = true;
|
|
154
|
+
test("non-zero exit from set-password doesn't abort the rest of the preflight", async () => {
|
|
155
|
+
const h = makeHarness(["y"]);
|
|
166
156
|
const interactiveRunner = async (cmd: readonly string[]) => {
|
|
167
157
|
h.commands.push([...cmd]);
|
|
168
|
-
|
|
169
|
-
first = false;
|
|
170
|
-
return 7;
|
|
171
|
-
}
|
|
172
|
-
return 0;
|
|
158
|
+
return 7;
|
|
173
159
|
};
|
|
174
160
|
await runAuthPreflight({
|
|
175
161
|
status: status(),
|
|
@@ -180,19 +166,22 @@ describe("runAuthPreflight — subprocess failure handling", () => {
|
|
|
180
166
|
},
|
|
181
167
|
interactiveRunner,
|
|
182
168
|
});
|
|
183
|
-
//
|
|
184
|
-
|
|
169
|
+
// The command was attempted; the flow continued (token guidance still
|
|
170
|
+
// printed afterward).
|
|
171
|
+
expect(h.commands.map((c) => c[0])).toEqual(["parachute"]);
|
|
185
172
|
const joined = h.logs.join("\n");
|
|
186
173
|
expect(joined).toContain("exited 7");
|
|
174
|
+
expect(joined).toContain("Bearer <hub-jwt>");
|
|
187
175
|
});
|
|
188
176
|
});
|
|
189
177
|
|
|
190
178
|
describe("runAuthPreflight — case-insensitive yes", () => {
|
|
191
179
|
test('"Y", "YES", and "y" all count as affirmative; anything else is decline', async () => {
|
|
180
|
+
// Now only the wide-open path prompts (for the password). Drive it there.
|
|
192
181
|
for (const yes of ["y", "Y", "yes", "YES"]) {
|
|
193
182
|
const h = makeHarness([yes]);
|
|
194
183
|
await runAuthPreflight({
|
|
195
|
-
status: status({ hasOwnerPassword:
|
|
184
|
+
status: status({ hasOwnerPassword: false }),
|
|
196
185
|
...wire(h),
|
|
197
186
|
});
|
|
198
187
|
expect(h.commands).toHaveLength(1);
|
|
@@ -200,7 +189,7 @@ describe("runAuthPreflight — case-insensitive yes", () => {
|
|
|
200
189
|
for (const no of ["", "n", "no", "q", "bogus"]) {
|
|
201
190
|
const h = makeHarness([no]);
|
|
202
191
|
await runAuthPreflight({
|
|
203
|
-
status: status({ hasOwnerPassword:
|
|
192
|
+
status: status({ hasOwnerPassword: false }),
|
|
204
193
|
...wire(h),
|
|
205
194
|
});
|
|
206
195
|
expect(h.commands).toHaveLength(0);
|