@openparachute/hub 0.6.3 → 0.6.4-rc.10
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 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { readPendingHostname, writePendingHostname } from "../cloudflare/state.ts";
|
|
5
6
|
import { exposePublicInteractive } from "../commands/expose-interactive.ts";
|
|
6
7
|
import { readLastProvider, writeLastProvider } from "../expose-last-provider.ts";
|
|
7
8
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
@@ -15,6 +16,7 @@ const noopPreflight = async () => {};
|
|
|
15
16
|
interface TestEnv {
|
|
16
17
|
cloudflaredHome: string;
|
|
17
18
|
lastProviderPath: string;
|
|
19
|
+
statePath: string;
|
|
18
20
|
cleanup: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -28,6 +30,7 @@ function makeEnv(opts: { cloudflaredLoggedIn?: boolean } = {}): TestEnv {
|
|
|
28
30
|
return {
|
|
29
31
|
cloudflaredHome,
|
|
30
32
|
lastProviderPath: join(dir, "expose-last-provider.json"),
|
|
33
|
+
statePath: join(dir, "cloudflared-state.json"),
|
|
31
34
|
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
32
35
|
};
|
|
33
36
|
}
|
|
@@ -194,6 +197,75 @@ describe("exposePublicInteractive — both ready", () => {
|
|
|
194
197
|
}
|
|
195
198
|
});
|
|
196
199
|
|
|
200
|
+
test("hub#567: persists the typed hostname as soon as it validates", async () => {
|
|
201
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
202
|
+
try {
|
|
203
|
+
const { runner } = fixedRunner({
|
|
204
|
+
tailscaleInstalled: true,
|
|
205
|
+
tailscaleLoggedIn: true,
|
|
206
|
+
tailscaleFunnelCap: true,
|
|
207
|
+
cloudflaredInstalled: true,
|
|
208
|
+
});
|
|
209
|
+
const { prompt } = queuePrompt(["2", "vault.example.com"]);
|
|
210
|
+
// exposeCloudflareUpImpl FAILS — so the hostname must survive for a retry.
|
|
211
|
+
const code = await exposePublicInteractive({
|
|
212
|
+
runner,
|
|
213
|
+
prompt,
|
|
214
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
215
|
+
lastProviderPath: env.lastProviderPath,
|
|
216
|
+
statePath: env.statePath,
|
|
217
|
+
log: () => {},
|
|
218
|
+
exposePublicImpl: async () => 0,
|
|
219
|
+
exposeCloudflareUpImpl: async () => 1,
|
|
220
|
+
runAuthPreflightImpl: noopPreflight,
|
|
221
|
+
});
|
|
222
|
+
expect(code).toBe(1);
|
|
223
|
+
// Stashed despite the downstream failure.
|
|
224
|
+
expect(readPendingHostname(env.statePath)).toBe("vault.example.com");
|
|
225
|
+
} finally {
|
|
226
|
+
env.cleanup();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("hub#567: pre-fills the hostname prompt from a stashed value; Enter accepts it", async () => {
|
|
231
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
232
|
+
try {
|
|
233
|
+
writePendingHostname("techne.parachute.computer", env.statePath);
|
|
234
|
+
const { runner } = fixedRunner({
|
|
235
|
+
tailscaleInstalled: true,
|
|
236
|
+
tailscaleLoggedIn: true,
|
|
237
|
+
tailscaleFunnelCap: true,
|
|
238
|
+
cloudflaredInstalled: true,
|
|
239
|
+
});
|
|
240
|
+
// Pick cloudflare, then press Enter (blank) at the hostname prompt.
|
|
241
|
+
const { prompt, asked } = queuePrompt(["2", ""]);
|
|
242
|
+
let cloudflareHostname: string | undefined;
|
|
243
|
+
const code = await exposePublicInteractive({
|
|
244
|
+
runner,
|
|
245
|
+
prompt,
|
|
246
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
247
|
+
lastProviderPath: env.lastProviderPath,
|
|
248
|
+
statePath: env.statePath,
|
|
249
|
+
log: () => {},
|
|
250
|
+
exposePublicImpl: async () => 0,
|
|
251
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
252
|
+
cloudflareHostname = h;
|
|
253
|
+
return 0;
|
|
254
|
+
},
|
|
255
|
+
runAuthPreflightImpl: noopPreflight,
|
|
256
|
+
});
|
|
257
|
+
expect(code).toBe(0);
|
|
258
|
+
// Enter accepted the stashed hostname.
|
|
259
|
+
expect(cloudflareHostname).toBe("techne.parachute.computer");
|
|
260
|
+
// The prompt surfaced the default in brackets.
|
|
261
|
+
expect(asked.some((q) => q.includes("[techne.parachute.computer]"))).toBe(true);
|
|
262
|
+
// Cleared once routing succeeded.
|
|
263
|
+
expect(readPendingHostname(env.statePath)).toBeUndefined();
|
|
264
|
+
} finally {
|
|
265
|
+
env.cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
197
269
|
test("'q' aborts cleanly with exit 0 and no downstream calls", async () => {
|
|
198
270
|
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
199
271
|
try {
|
|
@@ -440,11 +512,12 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
440
512
|
}
|
|
441
513
|
});
|
|
442
514
|
|
|
443
|
-
test("
|
|
515
|
+
test("hub#566: cloudflare on linux, user DECLINES auto-install: prints manual + --cloudflare hint, exits 1", async () => {
|
|
444
516
|
const env = makeEnv();
|
|
445
517
|
try {
|
|
446
518
|
const { runner } = fixedRunner({});
|
|
447
|
-
|
|
519
|
+
// "2" → cloudflare; "n" → decline the auto-install offer.
|
|
520
|
+
const { prompt } = queuePrompt(["2", "n"]);
|
|
448
521
|
const logs: string[] = [];
|
|
449
522
|
let interactiveCalled = false;
|
|
450
523
|
let cloudflareCalled = false;
|
|
@@ -456,8 +529,10 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
456
529
|
},
|
|
457
530
|
prompt,
|
|
458
531
|
platform: "linux",
|
|
532
|
+
arch: "x64",
|
|
459
533
|
cloudflaredHome: env.cloudflaredHome,
|
|
460
534
|
lastProviderPath: env.lastProviderPath,
|
|
535
|
+
statePath: env.statePath,
|
|
461
536
|
log: (l) => logs.push(l),
|
|
462
537
|
exposePublicImpl: async () => 0,
|
|
463
538
|
exposeCloudflareUpImpl: async () => {
|
|
@@ -466,16 +541,16 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
466
541
|
},
|
|
467
542
|
});
|
|
468
543
|
expect(code).toBe(1);
|
|
544
|
+
// Declining means no curl/chmod ran and we never reached the expose.
|
|
469
545
|
expect(interactiveCalled).toBe(false);
|
|
470
546
|
expect(cloudflareCalled).toBe(false);
|
|
471
547
|
const joined = logs.join("\n");
|
|
472
|
-
|
|
473
|
-
// off apt-get / dnf / developers.cloudflare.com (all unreliable —
|
|
474
|
-
// Aaron hit `No match for argument: cloudflared` on AL2023 and
|
|
475
|
-
// 404s from the docs URL on the same box) onto the static binary
|
|
476
|
-
// from GitHub releases.
|
|
548
|
+
expect(joined).toContain("Skipped auto-install");
|
|
477
549
|
expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
|
|
478
550
|
expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
551
|
+
// hub#566: re-run hint carries the --cloudflare flag (bare `expose
|
|
552
|
+
// public` defaults to Tailscale).
|
|
553
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
479
554
|
expect(joined).not.toContain("developers.cloudflare.com");
|
|
480
555
|
expect(joined).not.toContain("pkg.cloudflare.com");
|
|
481
556
|
expect(joined).not.toContain("sudo dnf install cloudflared");
|
|
@@ -484,6 +559,158 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
484
559
|
}
|
|
485
560
|
});
|
|
486
561
|
|
|
562
|
+
test("hub#566: cloudflare on linux as ROOT, accepts auto-install: runs bare curl+chmod (no sudo), then exposes", async () => {
|
|
563
|
+
const env = makeEnv();
|
|
564
|
+
try {
|
|
565
|
+
// cloudflared starts absent (so the install offer fires), then present
|
|
566
|
+
// after the install runs (so the verify probe + flow continue).
|
|
567
|
+
let cloudflaredPresent = false;
|
|
568
|
+
const runner: Runner = async (cmd) => {
|
|
569
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
570
|
+
return cloudflaredPresent
|
|
571
|
+
? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
|
|
572
|
+
: { code: 127, stdout: "", stderr: "not found" };
|
|
573
|
+
}
|
|
574
|
+
if (cmd[0] === "tailscale") {
|
|
575
|
+
// Detection: tailscale absent (forces the cloudflare-only path).
|
|
576
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
577
|
+
}
|
|
578
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
579
|
+
};
|
|
580
|
+
// "2" cloudflare → "Y" install → "Y" login → hostname. The login prompt
|
|
581
|
+
// fires because detection reported cloudflared absent (so loggedIn=false)
|
|
582
|
+
// even though cert.pem appears once login "runs".
|
|
583
|
+
const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
|
|
584
|
+
const interactiveCmds: string[][] = [];
|
|
585
|
+
const logs: string[] = [];
|
|
586
|
+
let cloudflareHostname = "";
|
|
587
|
+
const code = await exposePublicInteractive({
|
|
588
|
+
runner,
|
|
589
|
+
interactiveRunner: async (cmd) => {
|
|
590
|
+
interactiveCmds.push([...cmd]);
|
|
591
|
+
// Install "succeeds": flip cloudflared to present. Login "succeeds":
|
|
592
|
+
// drop the cert so `isCloudflaredLoggedIn` reads true afterward.
|
|
593
|
+
if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
|
|
594
|
+
else cloudflaredPresent = true;
|
|
595
|
+
return 0;
|
|
596
|
+
},
|
|
597
|
+
prompt,
|
|
598
|
+
platform: "linux",
|
|
599
|
+
arch: "x64",
|
|
600
|
+
getuid: () => 0, // root
|
|
601
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
602
|
+
lastProviderPath: env.lastProviderPath,
|
|
603
|
+
statePath: env.statePath,
|
|
604
|
+
log: (l) => logs.push(l),
|
|
605
|
+
exposePublicImpl: async () => 0,
|
|
606
|
+
exposeCloudflareUpImpl: async (hostname) => {
|
|
607
|
+
cloudflareHostname = hostname;
|
|
608
|
+
return 0;
|
|
609
|
+
},
|
|
610
|
+
runAuthPreflightImpl: noopPreflight,
|
|
611
|
+
});
|
|
612
|
+
expect(code).toBe(0);
|
|
613
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
614
|
+
// Root runs curl + chmod WITHOUT a sudo prefix.
|
|
615
|
+
expect(interactiveCmds[0]?.[0]).toBe("curl");
|
|
616
|
+
expect(interactiveCmds[0]).toContain("/usr/local/bin/cloudflared");
|
|
617
|
+
expect(interactiveCmds[1]?.[0]).toBe("chmod");
|
|
618
|
+
expect(interactiveCmds.some((c) => c[0] === "sudo")).toBe(false);
|
|
619
|
+
expect(logs.join("\n")).toContain("✓ cloudflared installed.");
|
|
620
|
+
} finally {
|
|
621
|
+
env.cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("hub#566: cloudflare on linux NON-root, accepts auto-install: wraps curl+chmod in sudo", async () => {
|
|
626
|
+
const env = makeEnv();
|
|
627
|
+
try {
|
|
628
|
+
let cloudflaredPresent = false;
|
|
629
|
+
const runner: Runner = async (cmd) => {
|
|
630
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
631
|
+
return cloudflaredPresent
|
|
632
|
+
? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
|
|
633
|
+
: { code: 127, stdout: "", stderr: "not found" };
|
|
634
|
+
}
|
|
635
|
+
if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
|
|
636
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
637
|
+
};
|
|
638
|
+
const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
|
|
639
|
+
const interactiveCmds: string[][] = [];
|
|
640
|
+
const code = await exposePublicInteractive({
|
|
641
|
+
runner,
|
|
642
|
+
interactiveRunner: async (cmd) => {
|
|
643
|
+
interactiveCmds.push([...cmd]);
|
|
644
|
+
if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
|
|
645
|
+
else cloudflaredPresent = true;
|
|
646
|
+
return 0;
|
|
647
|
+
},
|
|
648
|
+
prompt,
|
|
649
|
+
platform: "linux",
|
|
650
|
+
arch: "arm64",
|
|
651
|
+
getuid: () => 1000, // non-root
|
|
652
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
653
|
+
lastProviderPath: env.lastProviderPath,
|
|
654
|
+
statePath: env.statePath,
|
|
655
|
+
log: () => {},
|
|
656
|
+
exposePublicImpl: async () => 0,
|
|
657
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
658
|
+
runAuthPreflightImpl: noopPreflight,
|
|
659
|
+
});
|
|
660
|
+
expect(code).toBe(0);
|
|
661
|
+
// Non-root prefixes both privileged steps with non-interactive `sudo -n`
|
|
662
|
+
// (fails fast instead of hanging on a password prompt under a detached
|
|
663
|
+
// init).
|
|
664
|
+
expect(interactiveCmds[0]?.slice(0, 2)).toEqual(["sudo", "-n"]);
|
|
665
|
+
expect(interactiveCmds[0]).toContain("curl");
|
|
666
|
+
expect(interactiveCmds[1]?.slice(0, 2)).toEqual(["sudo", "-n"]);
|
|
667
|
+
expect(interactiveCmds[1]).toContain("chmod");
|
|
668
|
+
} finally {
|
|
669
|
+
env.cleanup();
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("hub#566: cloudflare on linux, sudo curl FAILS: prints manual + --cloudflare hint, exits 1", async () => {
|
|
674
|
+
const env = makeEnv();
|
|
675
|
+
try {
|
|
676
|
+
const runner: Runner = async (cmd) => {
|
|
677
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
678
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
679
|
+
}
|
|
680
|
+
if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
|
|
681
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
682
|
+
};
|
|
683
|
+
const { prompt } = queuePrompt(["2", "y"]);
|
|
684
|
+
const logs: string[] = [];
|
|
685
|
+
let cloudflareCalled = false;
|
|
686
|
+
const code = await exposePublicInteractive({
|
|
687
|
+
runner,
|
|
688
|
+
// Simulate sudo failing (no cached creds, no tty).
|
|
689
|
+
interactiveRunner: async () => 1,
|
|
690
|
+
prompt,
|
|
691
|
+
platform: "linux",
|
|
692
|
+
arch: "x64",
|
|
693
|
+
getuid: () => 1000,
|
|
694
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
695
|
+
lastProviderPath: env.lastProviderPath,
|
|
696
|
+
statePath: env.statePath,
|
|
697
|
+
log: (l) => logs.push(l),
|
|
698
|
+
exposePublicImpl: async () => 0,
|
|
699
|
+
exposeCloudflareUpImpl: async () => {
|
|
700
|
+
cloudflareCalled = true;
|
|
701
|
+
return 0;
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
expect(code).toBe(1);
|
|
705
|
+
expect(cloudflareCalled).toBe(false);
|
|
706
|
+
const joined = logs.join("\n");
|
|
707
|
+
expect(joined).toContain("Download failed");
|
|
708
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
709
|
+
} finally {
|
|
710
|
+
env.cleanup();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
487
714
|
test("user picks cloudflare on macos but declines brew: exits 1, no install attempted", async () => {
|
|
488
715
|
const env = makeEnv();
|
|
489
716
|
try {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ensureHubUnitForExpose, resolveExposeSupervisor } from "../commands/expose-supervisor.ts";
|
|
3
|
+
import type { EnsureHubVersionMatchesResult } from "../hub-unit.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #590: `ensureHubUnitForExpose` must run the version-check-and-restart at the
|
|
7
|
+
* expose adoption point, so an expose never wires a tunnel to a stale zombie
|
|
8
|
+
* that merely answers /health on the canonical port. These tests drive the
|
|
9
|
+
* version-check seam directly (no real launchctl / live hub).
|
|
10
|
+
*/
|
|
11
|
+
describe("ensureHubUnitForExpose — version-check at the expose adoption point (#590)", () => {
|
|
12
|
+
function sup(
|
|
13
|
+
ensureHubUnitOutcome: "already-up" | "started" | "no-unit",
|
|
14
|
+
versionResult: EnsureHubVersionMatchesResult,
|
|
15
|
+
versionSpy?: (port: number) => void,
|
|
16
|
+
) {
|
|
17
|
+
return resolveExposeSupervisor({
|
|
18
|
+
ensureHubUnit: async ({ port }) => ({
|
|
19
|
+
outcome: ensureHubUnitOutcome,
|
|
20
|
+
port: port ?? 1939,
|
|
21
|
+
messages: ensureHubUnitOutcome === "no-unit" ? ["no hub unit installed"] : [],
|
|
22
|
+
}),
|
|
23
|
+
ensureHubVersion: async ({ port }) => {
|
|
24
|
+
versionSpy?.(port);
|
|
25
|
+
return versionResult;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("hub up + version matches → ok, version check ran with the probed port", async () => {
|
|
31
|
+
const logs: string[] = [];
|
|
32
|
+
let checkedPort: number | undefined;
|
|
33
|
+
const s = sup(
|
|
34
|
+
"already-up",
|
|
35
|
+
{
|
|
36
|
+
outcome: "match",
|
|
37
|
+
runningVersion: "0.6.4-rc.9",
|
|
38
|
+
installedVersion: "0.6.4-rc.9",
|
|
39
|
+
messages: [],
|
|
40
|
+
},
|
|
41
|
+
(p) => {
|
|
42
|
+
checkedPort = p;
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
46
|
+
expect(res.ok).toBe(true);
|
|
47
|
+
expect(checkedPort).toBe(1939);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("hub up but a stale zombie → restarted → ok (tunnel binds to NEW code)", async () => {
|
|
51
|
+
const logs: string[] = [];
|
|
52
|
+
const s = sup("already-up", {
|
|
53
|
+
outcome: "restarted",
|
|
54
|
+
runningVersion: "0.6.4-rc.9",
|
|
55
|
+
installedVersion: "0.6.4-rc.9",
|
|
56
|
+
messages: ["✓ hub unit restarted; now running 0.6.4-rc.9."],
|
|
57
|
+
});
|
|
58
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
59
|
+
expect(res.ok).toBe(true);
|
|
60
|
+
expect(logs.join("\n")).toContain("now running 0.6.4-rc.9");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("hub up but mismatch + NOT unit-managed → expose FAILS (don't tunnel to a zombie)", async () => {
|
|
64
|
+
const logs: string[] = [];
|
|
65
|
+
const s = sup("already-up", {
|
|
66
|
+
outcome: "not-unit-managed",
|
|
67
|
+
runningVersion: "0.5.14-rc.4",
|
|
68
|
+
installedVersion: "0.6.4-rc.9",
|
|
69
|
+
messages: ["⚠ the running hub is 0.5.14-rc.4 but 0.6.4-rc.9 is installed."],
|
|
70
|
+
});
|
|
71
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
72
|
+
expect(res.ok).toBe(false);
|
|
73
|
+
expect(logs.join("\n")).toContain("0.5.14-rc.4");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("still-mismatched after restart → expose CONTINUES (warn, don't block)", async () => {
|
|
77
|
+
const logs: string[] = [];
|
|
78
|
+
const s = sup("already-up", {
|
|
79
|
+
outcome: "still-mismatched",
|
|
80
|
+
runningVersion: "0.6.4-rc.8",
|
|
81
|
+
installedVersion: "0.6.4-rc.9",
|
|
82
|
+
messages: ["⚠ restarted the hub unit, but it is still not reporting 0.6.4-rc.9."],
|
|
83
|
+
});
|
|
84
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
85
|
+
expect(res.ok).toBe(true);
|
|
86
|
+
expect(logs.join("\n")).toContain("still not reporting");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("hub NOT up (no unit) → fails BEFORE the version check (no false adoption)", async () => {
|
|
90
|
+
const logs: string[] = [];
|
|
91
|
+
let versionRan = false;
|
|
92
|
+
const s = sup(
|
|
93
|
+
"no-unit",
|
|
94
|
+
{ outcome: "match", installedVersion: "0.6.4-rc.9", messages: [] },
|
|
95
|
+
() => {
|
|
96
|
+
versionRan = true;
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
100
|
+
expect(res.ok).toBe(false);
|
|
101
|
+
// The version check only runs once the hub is confirmed up.
|
|
102
|
+
expect(versionRan).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -558,7 +558,9 @@ describe("expose tailnet up", () => {
|
|
|
558
558
|
},
|
|
559
559
|
});
|
|
560
560
|
expect(code).toBe(0);
|
|
561
|
-
expect(logs.join("\n")).not.toContain(
|
|
561
|
+
expect(logs.join("\n")).not.toContain(
|
|
562
|
+
"Strongly recommended: turn on two-factor authentication",
|
|
563
|
+
);
|
|
562
564
|
} finally {
|
|
563
565
|
h.cleanup();
|
|
564
566
|
}
|
|
@@ -931,9 +933,10 @@ describe("expose public up", () => {
|
|
|
931
933
|
});
|
|
932
934
|
expect(code).toBe(0);
|
|
933
935
|
const joined = logs.join("\n");
|
|
934
|
-
// hub#473: real hub-login 2FA. The
|
|
935
|
-
//
|
|
936
|
-
|
|
936
|
+
// hub#473: real hub-login 2FA. The recommendation now leads with the
|
|
937
|
+
// friendly "strongly recommended" framing and the real `parachute auth
|
|
938
|
+
// 2fa enroll` path.
|
|
939
|
+
expect(joined).toContain("Strongly recommended: turn on two-factor authentication");
|
|
937
940
|
expect(joined).toContain("parachute auth 2fa enroll");
|
|
938
941
|
// /login pointer uses the canonical https://<fqdn> origin.
|
|
939
942
|
expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
|
|
@@ -966,7 +969,9 @@ describe("expose public up", () => {
|
|
|
966
969
|
},
|
|
967
970
|
});
|
|
968
971
|
expect(code).toBe(0);
|
|
969
|
-
expect(logs.join("\n")).not.toContain(
|
|
972
|
+
expect(logs.join("\n")).not.toContain(
|
|
973
|
+
"Strongly recommended: turn on two-factor authentication",
|
|
974
|
+
);
|
|
970
975
|
} finally {
|
|
971
976
|
h.cleanup();
|
|
972
977
|
}
|