@openparachute/hub 0.3.0-rc.1 → 0.5.1
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 +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type CloudflaredTunnelRecord,
|
|
7
|
+
findTunnelRecord,
|
|
8
|
+
readCloudflaredState,
|
|
9
|
+
withTunnelRecord,
|
|
10
|
+
writeCloudflaredState,
|
|
11
|
+
} from "../cloudflare/state.ts";
|
|
6
12
|
import {
|
|
7
13
|
type CloudflaredSpawner,
|
|
8
14
|
exposeCloudflareOff,
|
|
@@ -29,8 +35,8 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
|
|
|
29
35
|
const cloudflaredHome = join(dir, "cloudflared");
|
|
30
36
|
const manifestPath = join(configDir, "services.json");
|
|
31
37
|
const statePath = join(configDir, "cloudflared-state.json");
|
|
32
|
-
const configPath = join(configDir, "cloudflared", "config.yml");
|
|
33
|
-
const logPath = join(configDir, "cloudflared", "cloudflared.log");
|
|
38
|
+
const configPath = join(configDir, "cloudflared", "parachute", "config.yml");
|
|
39
|
+
const logPath = join(configDir, "cloudflared", "parachute", "cloudflared.log");
|
|
34
40
|
|
|
35
41
|
require("node:fs").mkdirSync(configDir, { recursive: true });
|
|
36
42
|
require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
|
|
@@ -148,13 +154,17 @@ describe("exposeCloudflareUp", () => {
|
|
|
148
154
|
|
|
149
155
|
const state = readCloudflaredState(env.statePath);
|
|
150
156
|
expect(state).toEqual({
|
|
151
|
-
version:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
version: 2,
|
|
158
|
+
tunnels: {
|
|
159
|
+
parachute: {
|
|
160
|
+
pid: 42000,
|
|
161
|
+
tunnelUuid: uuid,
|
|
162
|
+
tunnelName: "parachute",
|
|
163
|
+
hostname: "vault.example.com",
|
|
164
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
165
|
+
configPath: env.configPath,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
158
168
|
});
|
|
159
169
|
|
|
160
170
|
const yaml = readFileSync(env.configPath, "utf8");
|
|
@@ -236,6 +246,33 @@ describe("exposeCloudflareUp", () => {
|
|
|
236
246
|
}
|
|
237
247
|
});
|
|
238
248
|
|
|
249
|
+
test("rejects invalid tunnel names up front", async () => {
|
|
250
|
+
const env = makeEnv();
|
|
251
|
+
try {
|
|
252
|
+
const { runner, calls } = queueRunner([]);
|
|
253
|
+
const { spawner } = fakeSpawner(0);
|
|
254
|
+
const logs: string[] = [];
|
|
255
|
+
|
|
256
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
257
|
+
runner,
|
|
258
|
+
spawner,
|
|
259
|
+
log: (l) => logs.push(l),
|
|
260
|
+
manifestPath: env.manifestPath,
|
|
261
|
+
statePath: env.statePath,
|
|
262
|
+
configPath: env.configPath,
|
|
263
|
+
logPath: env.logPath,
|
|
264
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
265
|
+
tunnelName: "bad name with spaces",
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(code).toBe(1);
|
|
269
|
+
expect(calls).toHaveLength(0);
|
|
270
|
+
expect(logs.join("\n")).toContain("--tunnel-name must be alphanumeric");
|
|
271
|
+
} finally {
|
|
272
|
+
env.cleanup();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
239
276
|
test("prints install hint when cloudflared is missing", async () => {
|
|
240
277
|
const env = makeEnv();
|
|
241
278
|
try {
|
|
@@ -353,18 +390,15 @@ describe("exposeCloudflareUp", () => {
|
|
|
353
390
|
test("stops a prior cloudflared process before spawning a new one", async () => {
|
|
354
391
|
const env = makeEnv();
|
|
355
392
|
try {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
},
|
|
366
|
-
env.statePath,
|
|
367
|
-
);
|
|
393
|
+
const priorRecord: CloudflaredTunnelRecord = {
|
|
394
|
+
pid: 99999,
|
|
395
|
+
tunnelUuid: "old-tunnel-uuid",
|
|
396
|
+
tunnelName: "parachute",
|
|
397
|
+
hostname: "vault.example.com",
|
|
398
|
+
startedAt: "2026-04-21T00:00:00.000Z",
|
|
399
|
+
configPath: env.configPath,
|
|
400
|
+
};
|
|
401
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: priorRecord } }, env.statePath);
|
|
368
402
|
|
|
369
403
|
const { runner } = queueRunner([
|
|
370
404
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
@@ -396,7 +430,83 @@ describe("exposeCloudflareUp", () => {
|
|
|
396
430
|
expect(code).toBe(0);
|
|
397
431
|
expect(killed).toEqual([{ pid: 99999, sig: "SIGTERM" }]);
|
|
398
432
|
const state = readCloudflaredState(env.statePath);
|
|
399
|
-
expect(state?.pid).toBe(42010);
|
|
433
|
+
expect(findTunnelRecord(state, "parachute")?.pid).toBe(42010);
|
|
434
|
+
} finally {
|
|
435
|
+
env.cleanup();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("two tunnels with different --tunnel-name coexist in state", async () => {
|
|
440
|
+
const env = makeEnv();
|
|
441
|
+
try {
|
|
442
|
+
const uuidA = "aaaa1111-aaaa-1111-aaaa-111111111111";
|
|
443
|
+
const uuidB = "bbbb2222-bbbb-2222-bbbb-222222222222";
|
|
444
|
+
// Up #1 — default name "parachute"
|
|
445
|
+
const r1 = queueRunner([
|
|
446
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
447
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
448
|
+
{
|
|
449
|
+
code: 0,
|
|
450
|
+
stdout: `Created tunnel parachute with id ${uuidA}\n`,
|
|
451
|
+
stderr: "",
|
|
452
|
+
},
|
|
453
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
454
|
+
]);
|
|
455
|
+
const s1 = fakeSpawner(50001);
|
|
456
|
+
const code1 = await exposeCloudflareUp("alpha.example.com", {
|
|
457
|
+
runner: r1.runner,
|
|
458
|
+
spawner: s1.spawner,
|
|
459
|
+
alive: () => false,
|
|
460
|
+
kill: () => {},
|
|
461
|
+
log: () => {},
|
|
462
|
+
manifestPath: env.manifestPath,
|
|
463
|
+
statePath: env.statePath,
|
|
464
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
465
|
+
// Use defaults for configPath/logPath so they're per-tunnel-derived.
|
|
466
|
+
});
|
|
467
|
+
expect(code1).toBe(0);
|
|
468
|
+
|
|
469
|
+
// Up #2 — explicit --tunnel-name "second"
|
|
470
|
+
const r2 = queueRunner([
|
|
471
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
472
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
473
|
+
{
|
|
474
|
+
code: 0,
|
|
475
|
+
stdout: `Created tunnel second with id ${uuidB}\n`,
|
|
476
|
+
stderr: "",
|
|
477
|
+
},
|
|
478
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
479
|
+
]);
|
|
480
|
+
const s2 = fakeSpawner(50002);
|
|
481
|
+
const code2 = await exposeCloudflareUp("beta.example.com", {
|
|
482
|
+
runner: r2.runner,
|
|
483
|
+
spawner: s2.spawner,
|
|
484
|
+
alive: () => false,
|
|
485
|
+
kill: () => {},
|
|
486
|
+
log: () => {},
|
|
487
|
+
manifestPath: env.manifestPath,
|
|
488
|
+
statePath: env.statePath,
|
|
489
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
490
|
+
tunnelName: "second",
|
|
491
|
+
});
|
|
492
|
+
expect(code2).toBe(0);
|
|
493
|
+
|
|
494
|
+
// Both tunnels should be present in state, keyed by tunnel name.
|
|
495
|
+
const state = readCloudflaredState(env.statePath);
|
|
496
|
+
expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual(["parachute", "second"]);
|
|
497
|
+
expect(findTunnelRecord(state, "parachute")?.hostname).toBe("alpha.example.com");
|
|
498
|
+
expect(findTunnelRecord(state, "second")?.hostname).toBe("beta.example.com");
|
|
499
|
+
expect(findTunnelRecord(state, "second")?.pid).toBe(50002);
|
|
500
|
+
|
|
501
|
+
// Each tunnel should have written its own config file at the per-tunnel
|
|
502
|
+
// path under `~/.parachute/cloudflared/<tunnelName>/config.yml`.
|
|
503
|
+
const cfgA = findTunnelRecord(state, "parachute")?.configPath ?? "";
|
|
504
|
+
const cfgB = findTunnelRecord(state, "second")?.configPath ?? "";
|
|
505
|
+
expect(cfgA).not.toBe(cfgB);
|
|
506
|
+
expect(cfgA.endsWith("/parachute/config.yml")).toBe(true);
|
|
507
|
+
expect(cfgB.endsWith("/second/config.yml")).toBe(true);
|
|
508
|
+
expect(existsSync(cfgA)).toBe(true);
|
|
509
|
+
expect(existsSync(cfgB)).toBe(true);
|
|
400
510
|
} finally {
|
|
401
511
|
env.cleanup();
|
|
402
512
|
}
|
|
@@ -424,13 +534,17 @@ describe("exposeCloudflareOff", () => {
|
|
|
424
534
|
try {
|
|
425
535
|
writeCloudflaredState(
|
|
426
536
|
{
|
|
427
|
-
version:
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
537
|
+
version: 2,
|
|
538
|
+
tunnels: {
|
|
539
|
+
parachute: {
|
|
540
|
+
pid: 55555,
|
|
541
|
+
tunnelUuid: "dddddddd-0000-0000-0000-000000000004",
|
|
542
|
+
tunnelName: "parachute",
|
|
543
|
+
hostname: "vault.example.com",
|
|
544
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
545
|
+
configPath: env.configPath,
|
|
546
|
+
},
|
|
547
|
+
},
|
|
434
548
|
},
|
|
435
549
|
env.statePath,
|
|
436
550
|
);
|
|
@@ -457,13 +571,17 @@ describe("exposeCloudflareOff", () => {
|
|
|
457
571
|
try {
|
|
458
572
|
writeCloudflaredState(
|
|
459
573
|
{
|
|
460
|
-
version:
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
574
|
+
version: 2,
|
|
575
|
+
tunnels: {
|
|
576
|
+
parachute: {
|
|
577
|
+
pid: 55556,
|
|
578
|
+
tunnelUuid: "eeeeeeee-0000-0000-0000-000000000005",
|
|
579
|
+
tunnelName: "parachute",
|
|
580
|
+
hostname: "vault.example.com",
|
|
581
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
582
|
+
configPath: env.configPath,
|
|
583
|
+
},
|
|
584
|
+
},
|
|
467
585
|
},
|
|
468
586
|
env.statePath,
|
|
469
587
|
);
|
|
@@ -481,4 +599,81 @@ describe("exposeCloudflareOff", () => {
|
|
|
481
599
|
env.cleanup();
|
|
482
600
|
}
|
|
483
601
|
});
|
|
602
|
+
|
|
603
|
+
test("targets the named tunnel and leaves siblings intact", async () => {
|
|
604
|
+
const env = makeEnv();
|
|
605
|
+
try {
|
|
606
|
+
const recordA: CloudflaredTunnelRecord = {
|
|
607
|
+
pid: 60001,
|
|
608
|
+
tunnelUuid: "aaaa-uuid",
|
|
609
|
+
tunnelName: "alpha",
|
|
610
|
+
hostname: "alpha.example.com",
|
|
611
|
+
startedAt: "2026-04-23T10:00:00.000Z",
|
|
612
|
+
configPath: "/tmp/alpha/config.yml",
|
|
613
|
+
};
|
|
614
|
+
const recordB: CloudflaredTunnelRecord = {
|
|
615
|
+
pid: 60002,
|
|
616
|
+
tunnelUuid: "bbbb-uuid",
|
|
617
|
+
tunnelName: "beta",
|
|
618
|
+
hostname: "beta.example.com",
|
|
619
|
+
startedAt: "2026-04-23T11:00:00.000Z",
|
|
620
|
+
configPath: "/tmp/beta/config.yml",
|
|
621
|
+
};
|
|
622
|
+
writeCloudflaredState(
|
|
623
|
+
withTunnelRecord(withTunnelRecord(undefined, recordA), recordB),
|
|
624
|
+
env.statePath,
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const killed: number[] = [];
|
|
628
|
+
const code = await exposeCloudflareOff({
|
|
629
|
+
statePath: env.statePath,
|
|
630
|
+
alive: () => true,
|
|
631
|
+
kill: (pid) => killed.push(pid),
|
|
632
|
+
log: () => {},
|
|
633
|
+
tunnelName: "alpha",
|
|
634
|
+
});
|
|
635
|
+
expect(code).toBe(0);
|
|
636
|
+
// Only alpha's pid is killed.
|
|
637
|
+
expect(killed).toEqual([60001]);
|
|
638
|
+
|
|
639
|
+
// beta is still recorded; alpha is gone.
|
|
640
|
+
const state = readCloudflaredState(env.statePath);
|
|
641
|
+
expect(findTunnelRecord(state, "alpha")).toBeUndefined();
|
|
642
|
+
expect(findTunnelRecord(state, "beta")).toEqual(recordB);
|
|
643
|
+
} finally {
|
|
644
|
+
env.cleanup();
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("reports tunnel-name mismatch and lists known tunnels", async () => {
|
|
649
|
+
const env = makeEnv();
|
|
650
|
+
try {
|
|
651
|
+
const recordA: CloudflaredTunnelRecord = {
|
|
652
|
+
pid: 60001,
|
|
653
|
+
tunnelUuid: "aaaa-uuid",
|
|
654
|
+
tunnelName: "alpha",
|
|
655
|
+
hostname: "alpha.example.com",
|
|
656
|
+
startedAt: "2026-04-23T10:00:00.000Z",
|
|
657
|
+
configPath: "/tmp/alpha/config.yml",
|
|
658
|
+
};
|
|
659
|
+
writeCloudflaredState({ version: 2, tunnels: { alpha: recordA } }, env.statePath);
|
|
660
|
+
|
|
661
|
+
const logs: string[] = [];
|
|
662
|
+
const code = await exposeCloudflareOff({
|
|
663
|
+
statePath: env.statePath,
|
|
664
|
+
alive: () => true,
|
|
665
|
+
kill: () => {},
|
|
666
|
+
log: (l) => logs.push(l),
|
|
667
|
+
tunnelName: "ghost",
|
|
668
|
+
});
|
|
669
|
+
expect(code).toBe(0);
|
|
670
|
+
expect(logs.join("\n")).toContain('No Cloudflare exposure recorded for tunnel "ghost"');
|
|
671
|
+
expect(logs.join("\n")).toContain("alpha");
|
|
672
|
+
// alpha is untouched.
|
|
673
|
+
const state = readCloudflaredState(env.statePath);
|
|
674
|
+
expect(findTunnelRecord(state, "alpha")).toEqual(recordA);
|
|
675
|
+
} finally {
|
|
676
|
+
env.cleanup();
|
|
677
|
+
}
|
|
678
|
+
});
|
|
484
679
|
});
|
|
@@ -26,16 +26,22 @@ function tailscaleState(overrides: Partial<ExposeState> = {}): ExposeState {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function cloudflaredState(
|
|
29
|
+
function cloudflaredState(
|
|
30
|
+
overrides: { hostname?: string; tunnelName?: string; pid?: number } = {},
|
|
31
|
+
): CloudflaredState {
|
|
32
|
+
const tunnelName = overrides.tunnelName ?? "vault-tunnel";
|
|
30
33
|
return {
|
|
31
|
-
version:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
version: 2,
|
|
35
|
+
tunnels: {
|
|
36
|
+
[tunnelName]: {
|
|
37
|
+
pid: overrides.pid ?? 4242,
|
|
38
|
+
tunnelUuid: "11111111-2222-3333-4444-555555555555",
|
|
39
|
+
tunnelName,
|
|
40
|
+
hostname: overrides.hostname ?? "vault.example.com",
|
|
41
|
+
startedAt: "2026-04-23T10:00:00.000Z",
|
|
42
|
+
configPath: "/tmp/config.yml",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
39
45
|
};
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { exposePublicAutoPick } from "../commands/expose-public-auto.ts";
|
|
3
|
+
import type { ProviderAvailability } from "../providers/detect.ts";
|
|
4
|
+
|
|
5
|
+
function availability(opts: {
|
|
6
|
+
tailnet?: { available?: boolean; loggedIn?: boolean; funnelEnabled?: boolean };
|
|
7
|
+
cloudflare?: { available?: boolean; loggedIn?: boolean };
|
|
8
|
+
}): ProviderAvailability {
|
|
9
|
+
return {
|
|
10
|
+
tailnet: {
|
|
11
|
+
available: opts.tailnet?.available ?? false,
|
|
12
|
+
loggedIn: opts.tailnet?.loggedIn ?? false,
|
|
13
|
+
funnelEnabled: opts.tailnet?.funnelEnabled ?? false,
|
|
14
|
+
},
|
|
15
|
+
cloudflare: {
|
|
16
|
+
available: opts.cloudflare?.available ?? false,
|
|
17
|
+
loggedIn: opts.cloudflare?.loggedIn ?? false,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("exposePublicAutoPick — exactly one ready", () => {
|
|
23
|
+
test("only tailnet ready → runs exposePublic('up') without prompting", async () => {
|
|
24
|
+
let called: { action: string; opts: unknown } | undefined;
|
|
25
|
+
const code = await exposePublicAutoPick({
|
|
26
|
+
tailscaleOpts: { hubOrigin: "https://override.example" },
|
|
27
|
+
log: () => {},
|
|
28
|
+
detectProvidersImpl: async () =>
|
|
29
|
+
availability({ tailnet: { available: true, loggedIn: true, funnelEnabled: true } }),
|
|
30
|
+
exposePublicImpl: async (action, opts) => {
|
|
31
|
+
called = { action, opts };
|
|
32
|
+
return 0;
|
|
33
|
+
},
|
|
34
|
+
exposeCloudflareUpImpl: async () => {
|
|
35
|
+
throw new Error("must not be called when only tailnet is ready");
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(code).toBe(0);
|
|
39
|
+
expect(called).toEqual({
|
|
40
|
+
action: "up",
|
|
41
|
+
opts: { hubOrigin: "https://override.example" },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("only cloudflare ready + --domain given → runs exposeCloudflareUp", async () => {
|
|
46
|
+
let receivedHostname: string | undefined;
|
|
47
|
+
let receivedOpts: unknown;
|
|
48
|
+
const code = await exposePublicAutoPick({
|
|
49
|
+
domain: "vault.example.com",
|
|
50
|
+
tunnelName: "vault",
|
|
51
|
+
log: () => {},
|
|
52
|
+
detectProvidersImpl: async () =>
|
|
53
|
+
availability({ cloudflare: { available: true, loggedIn: true } }),
|
|
54
|
+
exposePublicImpl: async () => {
|
|
55
|
+
throw new Error("must not be called when only cloudflare is ready");
|
|
56
|
+
},
|
|
57
|
+
exposeCloudflareUpImpl: async (hostname, opts) => {
|
|
58
|
+
receivedHostname = hostname;
|
|
59
|
+
receivedOpts = opts;
|
|
60
|
+
return 0;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
expect(code).toBe(0);
|
|
64
|
+
expect(receivedHostname).toBe("vault.example.com");
|
|
65
|
+
expect(receivedOpts).toEqual({ tunnelName: "vault" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("only cloudflare ready + no --domain → exits 1 with hostname-required hint", async () => {
|
|
69
|
+
const logs: string[] = [];
|
|
70
|
+
const code = await exposePublicAutoPick({
|
|
71
|
+
log: (l) => logs.push(l),
|
|
72
|
+
detectProvidersImpl: async () =>
|
|
73
|
+
availability({ cloudflare: { available: true, loggedIn: true } }),
|
|
74
|
+
exposePublicImpl: async () => {
|
|
75
|
+
throw new Error("must not be called");
|
|
76
|
+
},
|
|
77
|
+
exposeCloudflareUpImpl: async () => {
|
|
78
|
+
throw new Error("must not be called");
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
expect(code).toBe(1);
|
|
82
|
+
const joined = logs.join("\n");
|
|
83
|
+
expect(joined).toMatch(/--domain <hostname> is required|--domain.+required/);
|
|
84
|
+
expect(joined).toContain("--cloudflare --domain vault.example.com");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("exposePublicAutoPick — ambiguous", () => {
|
|
89
|
+
test("both ready → exits 1 pointing to --tailnet/--cloudflare", async () => {
|
|
90
|
+
const logs: string[] = [];
|
|
91
|
+
const code = await exposePublicAutoPick({
|
|
92
|
+
log: (l) => logs.push(l),
|
|
93
|
+
detectProvidersImpl: async () =>
|
|
94
|
+
availability({
|
|
95
|
+
tailnet: { available: true, loggedIn: true, funnelEnabled: true },
|
|
96
|
+
cloudflare: { available: true, loggedIn: true },
|
|
97
|
+
}),
|
|
98
|
+
exposePublicImpl: async () => {
|
|
99
|
+
throw new Error("must not be called when ambiguous");
|
|
100
|
+
},
|
|
101
|
+
exposeCloudflareUpImpl: async () => {
|
|
102
|
+
throw new Error("must not be called when ambiguous");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
expect(code).toBe(1);
|
|
106
|
+
const joined = logs.join("\n");
|
|
107
|
+
expect(joined).toContain("--tailnet");
|
|
108
|
+
expect(joined).toContain("--cloudflare");
|
|
109
|
+
expect(joined).toContain("--skip-provider-check");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("exposePublicAutoPick — neither ready", () => {
|
|
114
|
+
test("no providers → exits 1 with install pointers for both", async () => {
|
|
115
|
+
const logs: string[] = [];
|
|
116
|
+
const code = await exposePublicAutoPick({
|
|
117
|
+
log: (l) => logs.push(l),
|
|
118
|
+
detectProvidersImpl: async () => availability({}),
|
|
119
|
+
exposePublicImpl: async () => {
|
|
120
|
+
throw new Error("must not be called");
|
|
121
|
+
},
|
|
122
|
+
exposeCloudflareUpImpl: async () => {
|
|
123
|
+
throw new Error("must not be called");
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
expect(code).toBe(1);
|
|
127
|
+
const joined = logs.join("\n");
|
|
128
|
+
expect(joined).toContain("tailscale.com/download");
|
|
129
|
+
expect(joined).toContain("developers.cloudflare.com");
|
|
130
|
+
expect(joined).toContain("--skip-provider-check");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("partial readiness (installed but not logged in) lists the next step", async () => {
|
|
134
|
+
const logs: string[] = [];
|
|
135
|
+
const code = await exposePublicAutoPick({
|
|
136
|
+
log: (l) => logs.push(l),
|
|
137
|
+
detectProvidersImpl: async () =>
|
|
138
|
+
availability({
|
|
139
|
+
tailnet: { available: true, loggedIn: false, funnelEnabled: false },
|
|
140
|
+
cloudflare: { available: true, loggedIn: false },
|
|
141
|
+
}),
|
|
142
|
+
exposePublicImpl: async () => 0,
|
|
143
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
144
|
+
});
|
|
145
|
+
expect(code).toBe(1);
|
|
146
|
+
const joined = logs.join("\n");
|
|
147
|
+
// Both binaries marked installed; both still need login.
|
|
148
|
+
expect(joined).toContain("✓ tailscale installed");
|
|
149
|
+
expect(joined).toContain("✓ cloudflared installed");
|
|
150
|
+
expect(joined).toContain("tailscale up");
|
|
151
|
+
expect(joined).toContain("cloudflared tunnel login");
|
|
152
|
+
});
|
|
153
|
+
});
|