@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -11,9 +11,18 @@ import {
|
|
|
11
11
|
start,
|
|
12
12
|
stop,
|
|
13
13
|
} from "../commands/lifecycle.ts";
|
|
14
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
14
15
|
import { writeHubPort } from "../hub-control.ts";
|
|
16
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
18
|
+
import {
|
|
19
|
+
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
20
|
+
issueOperatorToken,
|
|
21
|
+
readOperatorTokenFile,
|
|
22
|
+
} from "../operator-token.ts";
|
|
15
23
|
import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
|
|
16
|
-
import { upsertService } from "../services-manifest.ts";
|
|
24
|
+
import { readManifest, upsertService } from "../services-manifest.ts";
|
|
25
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
17
26
|
|
|
18
27
|
interface Harness {
|
|
19
28
|
configDir: string;
|
|
@@ -188,6 +197,87 @@ describe("parachute start", () => {
|
|
|
188
197
|
}
|
|
189
198
|
});
|
|
190
199
|
|
|
200
|
+
test("missing startCmd binary → friendly missing-dependency message + no spawn", async () => {
|
|
201
|
+
const h = makeHarness();
|
|
202
|
+
try {
|
|
203
|
+
seedVault(h.manifestPath);
|
|
204
|
+
const spawner = makeSpawner([4242]);
|
|
205
|
+
const logs: string[] = [];
|
|
206
|
+
const code = await start("vault", {
|
|
207
|
+
configDir: h.configDir,
|
|
208
|
+
manifestPath: h.manifestPath,
|
|
209
|
+
spawner,
|
|
210
|
+
// Force the preflight's missing-binary branch: parachute-vault not on PATH.
|
|
211
|
+
which: () => null,
|
|
212
|
+
log: (l) => logs.push(l),
|
|
213
|
+
});
|
|
214
|
+
expect(code).toBe(1);
|
|
215
|
+
// Preflight fired before the spawn — the stub spawner is never called.
|
|
216
|
+
expect(spawner.calls).toHaveLength(0);
|
|
217
|
+
const out = logs.join("\n");
|
|
218
|
+
expect(out).toMatch(/vault failed to start/);
|
|
219
|
+
// The friendly install block names the binary + its install path.
|
|
220
|
+
expect(out).toContain("parachute-vault is required to run the Vault module Hub supervises");
|
|
221
|
+
expect(out).toContain("parachute install vault");
|
|
222
|
+
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
223
|
+
} finally {
|
|
224
|
+
h.cleanup();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("missing startCmd binary persists lastStartError so a later status surfaces it", async () => {
|
|
229
|
+
const h = makeHarness();
|
|
230
|
+
try {
|
|
231
|
+
seedVault(h.manifestPath);
|
|
232
|
+
await start("vault", {
|
|
233
|
+
configDir: h.configDir,
|
|
234
|
+
manifestPath: h.manifestPath,
|
|
235
|
+
spawner: makeSpawner([4242]),
|
|
236
|
+
which: () => null,
|
|
237
|
+
log: () => {},
|
|
238
|
+
});
|
|
239
|
+
const entry = readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault");
|
|
240
|
+
expect(entry?.lastStartError?.error_type).toBe("missing_dependency");
|
|
241
|
+
expect(entry?.lastStartError?.binary).toBe("parachute-vault");
|
|
242
|
+
expect(entry?.lastStartError?.at).toBeDefined();
|
|
243
|
+
} finally {
|
|
244
|
+
h.cleanup();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a successful start clears a previously-recorded lastStartError", async () => {
|
|
249
|
+
const h = makeHarness();
|
|
250
|
+
try {
|
|
251
|
+
seedVault(h.manifestPath);
|
|
252
|
+
// First start fails (binary missing) → records the error.
|
|
253
|
+
await start("vault", {
|
|
254
|
+
configDir: h.configDir,
|
|
255
|
+
manifestPath: h.manifestPath,
|
|
256
|
+
spawner: makeSpawner([1]),
|
|
257
|
+
which: () => null,
|
|
258
|
+
log: () => {},
|
|
259
|
+
});
|
|
260
|
+
expect(
|
|
261
|
+
readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
|
|
262
|
+
?.lastStartError,
|
|
263
|
+
).toBeDefined();
|
|
264
|
+
// Second start succeeds (binary present via the permissive default which
|
|
265
|
+
// — stub spawner path) → clears the recorded error.
|
|
266
|
+
await start("vault", {
|
|
267
|
+
configDir: h.configDir,
|
|
268
|
+
manifestPath: h.manifestPath,
|
|
269
|
+
spawner: makeSpawner([4242]),
|
|
270
|
+
log: () => {},
|
|
271
|
+
});
|
|
272
|
+
expect(
|
|
273
|
+
readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
|
|
274
|
+
?.lastStartError,
|
|
275
|
+
).toBeUndefined();
|
|
276
|
+
} finally {
|
|
277
|
+
h.cleanup();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
191
281
|
test("notes start command includes configured port and notes-serve shim path", async () => {
|
|
192
282
|
const h = makeHarness();
|
|
193
283
|
try {
|
|
@@ -348,6 +438,82 @@ describe("parachute start", () => {
|
|
|
348
438
|
PORT: "1940",
|
|
349
439
|
PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
|
|
350
440
|
});
|
|
441
|
+
// OAuth issuer-mismatch fix: the spawn-env injection above is ephemeral
|
|
442
|
+
// (lost on the next launchd / systemd boot). `start vault` ALSO persists
|
|
443
|
+
// the public origin into vault/.env so the out-of-band daemon validates
|
|
444
|
+
// hub-minted JWTs' `iss` against it. Without this, every reconnect after
|
|
445
|
+
// a reboot / crash-restart 401s.
|
|
446
|
+
expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
447
|
+
"https://parachute.taildf9ce2.ts.net",
|
|
448
|
+
);
|
|
449
|
+
} finally {
|
|
450
|
+
h.cleanup();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
|
|
455
|
+
// Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
|
|
456
|
+
// loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
|
|
457
|
+
// wrote loopback). expose-state.json carries the real public origin. A
|
|
458
|
+
// plain `parachute start vault` must rewrite vault/.env to the public
|
|
459
|
+
// origin so the daemon stops 401ing hub tokens — the self-heal half of the
|
|
460
|
+
// Cloudflare 401 fix.
|
|
461
|
+
const h = makeHarness();
|
|
462
|
+
try {
|
|
463
|
+
seedVault(h.manifestPath);
|
|
464
|
+
writeFileSync(
|
|
465
|
+
join(h.configDir, "expose-state.json"),
|
|
466
|
+
JSON.stringify({
|
|
467
|
+
version: 1,
|
|
468
|
+
layer: "public",
|
|
469
|
+
mode: "subdomain",
|
|
470
|
+
canonicalFqdn: "gitcoin-parachute.unforced.dev",
|
|
471
|
+
port: 1939,
|
|
472
|
+
funnel: false,
|
|
473
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
474
|
+
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
// Pre-seed vault/.env with a stale loopback value (the broken state).
|
|
478
|
+
mkdirSync(join(h.configDir, "vault"), { recursive: true });
|
|
479
|
+
writeFileSync(
|
|
480
|
+
join(h.configDir, "vault", ".env"),
|
|
481
|
+
"PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
|
|
482
|
+
);
|
|
483
|
+
const spawner = makeSpawner([4242]);
|
|
484
|
+
const code = await start("vault", {
|
|
485
|
+
configDir: h.configDir,
|
|
486
|
+
manifestPath: h.manifestPath,
|
|
487
|
+
spawner,
|
|
488
|
+
log: () => {},
|
|
489
|
+
});
|
|
490
|
+
expect(code).toBe(0);
|
|
491
|
+
expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
492
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
493
|
+
);
|
|
494
|
+
} finally {
|
|
495
|
+
h.cleanup();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
|
|
500
|
+
const h = makeHarness();
|
|
501
|
+
try {
|
|
502
|
+
seedVault(h.manifestPath);
|
|
503
|
+
writeHubPort(1939, h.configDir);
|
|
504
|
+
const spawner = makeSpawner([4242]);
|
|
505
|
+
const code = await start("vault", {
|
|
506
|
+
configDir: h.configDir,
|
|
507
|
+
manifestPath: h.manifestPath,
|
|
508
|
+
spawner,
|
|
509
|
+
log: () => {},
|
|
510
|
+
});
|
|
511
|
+
expect(code).toBe(0);
|
|
512
|
+
// Loopback is fine to inject into the ephemeral spawn env (local dev),
|
|
513
|
+
// but persisting it would brick the daemon path once exposure comes up:
|
|
514
|
+
// the baked loopback would shadow the real origin. So vault/.env stays
|
|
515
|
+
// absent of the key on a loopback-only start.
|
|
516
|
+
expect(existsSync(join(h.configDir, "vault", ".env"))).toBe(false);
|
|
351
517
|
} finally {
|
|
352
518
|
h.cleanup();
|
|
353
519
|
}
|
|
@@ -751,6 +917,159 @@ describe("parachute start", () => {
|
|
|
751
917
|
h.cleanup();
|
|
752
918
|
}
|
|
753
919
|
});
|
|
920
|
+
|
|
921
|
+
// hub#487 — readiness gating beyond the bare liveness settle. Aaron hit this
|
|
922
|
+
// on a fresh EC2 box: `parachute start vault` printed "✓ vault started" while
|
|
923
|
+
// the process died ~instantly on EADDRINUSE (an orphan held 1940), and
|
|
924
|
+
// `parachute status` then showed it inactive.
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* A stub spawner that also seeds the service's log file with `content`, so
|
|
928
|
+
* the readiness-failure path's log-tail + EADDRINUSE detection can read a
|
|
929
|
+
* realistic boot error. Mirrors how the real spawner appends stdout/stderr
|
|
930
|
+
* to the logfile.
|
|
931
|
+
*/
|
|
932
|
+
function makeSpawnerWithLog(pid: number, content: string): SpawnerStub {
|
|
933
|
+
const calls: SpawnerStub["calls"] = [];
|
|
934
|
+
return {
|
|
935
|
+
calls,
|
|
936
|
+
spawn(cmd, logFile, opts) {
|
|
937
|
+
calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
|
|
938
|
+
// The start path calls ensureLogPath() before spawn, so logFile's
|
|
939
|
+
// parent dir already exists — just write the simulated boot output.
|
|
940
|
+
writeFileSync(logFile, content);
|
|
941
|
+
return pid;
|
|
942
|
+
},
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
test("hub#487: EADDRINUSE in the log → port-in-use message + log tail, not ✓", async () => {
|
|
947
|
+
const h = makeHarness();
|
|
948
|
+
try {
|
|
949
|
+
seedVault(h.manifestPath);
|
|
950
|
+
const spawner = makeSpawnerWithLog(
|
|
951
|
+
4242,
|
|
952
|
+
"booting vault…\nerror: listen EADDRINUSE: address already in use 0.0.0.0:1940\n",
|
|
953
|
+
);
|
|
954
|
+
const lines: string[] = [];
|
|
955
|
+
const code = await start("vault", {
|
|
956
|
+
configDir: h.configDir,
|
|
957
|
+
manifestPath: h.manifestPath,
|
|
958
|
+
spawner,
|
|
959
|
+
alive: () => false, // process died right after the EADDRINUSE throw
|
|
960
|
+
sleep: async () => {},
|
|
961
|
+
startSettleMs: 1,
|
|
962
|
+
log: (l) => lines.push(l),
|
|
963
|
+
});
|
|
964
|
+
expect(code).toBe(1);
|
|
965
|
+
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
966
|
+
const out = lines.join("\n");
|
|
967
|
+
expect(out).toMatch(/port 1940 is already in use/);
|
|
968
|
+
expect(out).toMatch(/lsof -ti:1940/);
|
|
969
|
+
// The real boot error is surfaced inline so the operator doesn't have to
|
|
970
|
+
// go tail the log themselves.
|
|
971
|
+
expect(out).toMatch(/EADDRINUSE/);
|
|
972
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
973
|
+
} finally {
|
|
974
|
+
h.cleanup();
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("hub#487: process survives settle but never binds its port → failure with log tail", async () => {
|
|
979
|
+
const h = makeHarness();
|
|
980
|
+
try {
|
|
981
|
+
seedVault(h.manifestPath);
|
|
982
|
+
const spawner = makeSpawnerWithLog(4242, "vault crashed mid-boot\n");
|
|
983
|
+
const lines: string[] = [];
|
|
984
|
+
let aliveCalls = 0;
|
|
985
|
+
const code = await start("vault", {
|
|
986
|
+
configDir: h.configDir,
|
|
987
|
+
manifestPath: h.manifestPath,
|
|
988
|
+
spawner,
|
|
989
|
+
// Alive through the settle + first readiness poll, then dies — the
|
|
990
|
+
// slow-EADDRINUSE / crash-after-boot shape.
|
|
991
|
+
alive: () => {
|
|
992
|
+
aliveCalls++;
|
|
993
|
+
return aliveCalls <= 1;
|
|
994
|
+
},
|
|
995
|
+
sleep: async () => {},
|
|
996
|
+
startSettleMs: 1,
|
|
997
|
+
startReadyMs: 50,
|
|
998
|
+
startReadyPollMs: 1,
|
|
999
|
+
portListening: async () => false, // never binds
|
|
1000
|
+
log: (l) => lines.push(l),
|
|
1001
|
+
});
|
|
1002
|
+
expect(code).toBe(1);
|
|
1003
|
+
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
1004
|
+
const out = lines.join("\n");
|
|
1005
|
+
expect(out).toMatch(/✗ vault failed to start/);
|
|
1006
|
+
expect(out).toMatch(/exited during startup/);
|
|
1007
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
1008
|
+
} finally {
|
|
1009
|
+
h.cleanup();
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test("hub#487: alive but port silent past the window → non-fatal warning, exit 0", async () => {
|
|
1014
|
+
const h = makeHarness();
|
|
1015
|
+
try {
|
|
1016
|
+
seedVault(h.manifestPath);
|
|
1017
|
+
const spawner = makeSpawner([4242]);
|
|
1018
|
+
const lines: string[] = [];
|
|
1019
|
+
const code = await start("vault", {
|
|
1020
|
+
configDir: h.configDir,
|
|
1021
|
+
manifestPath: h.manifestPath,
|
|
1022
|
+
spawner,
|
|
1023
|
+
alive: () => true, // stays up the whole time
|
|
1024
|
+
sleep: async () => {},
|
|
1025
|
+
startSettleMs: 1,
|
|
1026
|
+
startReadyMs: 10,
|
|
1027
|
+
startReadyPollMs: 1,
|
|
1028
|
+
portListening: async () => false, // slow boot — not listening yet
|
|
1029
|
+
log: (l) => lines.push(l),
|
|
1030
|
+
});
|
|
1031
|
+
// A slow-but-alive daemon isn't a hard failure — we warn rather than fail.
|
|
1032
|
+
expect(code).toBe(0);
|
|
1033
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
1034
|
+
const out = lines.join("\n");
|
|
1035
|
+
expect(out).toMatch(/port 1940 isn't accepting connections yet/);
|
|
1036
|
+
expect(out).not.toMatch(/✓ vault started/);
|
|
1037
|
+
} finally {
|
|
1038
|
+
h.cleanup();
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
test("hub#487: alive + port listening → success", async () => {
|
|
1043
|
+
const h = makeHarness();
|
|
1044
|
+
try {
|
|
1045
|
+
seedVault(h.manifestPath);
|
|
1046
|
+
const spawner = makeSpawner([4242]);
|
|
1047
|
+
const lines: string[] = [];
|
|
1048
|
+
let probeCalls = 0;
|
|
1049
|
+
const code = await start("vault", {
|
|
1050
|
+
configDir: h.configDir,
|
|
1051
|
+
manifestPath: h.manifestPath,
|
|
1052
|
+
spawner,
|
|
1053
|
+
alive: () => true,
|
|
1054
|
+
sleep: async () => {},
|
|
1055
|
+
startSettleMs: 1,
|
|
1056
|
+
startReadyMs: 50,
|
|
1057
|
+
startReadyPollMs: 1,
|
|
1058
|
+
// Not listening on the first poll, bound on the second — exercises the
|
|
1059
|
+
// poll loop rather than an instant true.
|
|
1060
|
+
portListening: async () => {
|
|
1061
|
+
probeCalls++;
|
|
1062
|
+
return probeCalls >= 2;
|
|
1063
|
+
},
|
|
1064
|
+
log: (l) => lines.push(l),
|
|
1065
|
+
});
|
|
1066
|
+
expect(code).toBe(0);
|
|
1067
|
+
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
1068
|
+
expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
|
|
1069
|
+
} finally {
|
|
1070
|
+
h.cleanup();
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
754
1073
|
});
|
|
755
1074
|
|
|
756
1075
|
describe("parachute stop", () => {
|
|
@@ -1295,6 +1614,149 @@ describe("parachute start|stop|restart hub", () => {
|
|
|
1295
1614
|
}
|
|
1296
1615
|
});
|
|
1297
1616
|
|
|
1617
|
+
// hub#481 — `start hub` self-heals a stale operator-token issuer. Tests use
|
|
1618
|
+
// the injectable `hub.selfHealOperatorToken` seam to assert the call happens
|
|
1619
|
+
// (and to make it throw without failing start); a separate test drives the
|
|
1620
|
+
// REAL self-heal against an on-disk operator token + hub.db.
|
|
1621
|
+
test("start hub: invokes operator-token self-heal with the resolved issuer + configDir", async () => {
|
|
1622
|
+
const h = makeHarness();
|
|
1623
|
+
try {
|
|
1624
|
+
const log: string[] = [];
|
|
1625
|
+
const calls: Array<{ issuer: string; configDir: string }> = [];
|
|
1626
|
+
const code = await start("hub", {
|
|
1627
|
+
configDir: h.configDir,
|
|
1628
|
+
manifestPath: h.manifestPath,
|
|
1629
|
+
hubOrigin: "https://hub.example.com",
|
|
1630
|
+
hub: {
|
|
1631
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1632
|
+
selfHealOperatorToken: async (args) => {
|
|
1633
|
+
calls.push({ issuer: args.issuer, configDir: args.configDir });
|
|
1634
|
+
return {
|
|
1635
|
+
kind: "rotated",
|
|
1636
|
+
path: "/x/operator.token",
|
|
1637
|
+
scopeSet: "admin",
|
|
1638
|
+
expiresAt: "z",
|
|
1639
|
+
};
|
|
1640
|
+
},
|
|
1641
|
+
},
|
|
1642
|
+
log: (l) => log.push(l),
|
|
1643
|
+
});
|
|
1644
|
+
expect(code).toBe(0);
|
|
1645
|
+
expect(calls).toEqual([{ issuer: "https://hub.example.com", configDir: h.configDir }]);
|
|
1646
|
+
// Rotation emits an operator-facing line.
|
|
1647
|
+
expect(log.join("\n")).toMatch(
|
|
1648
|
+
/refreshed operator\.token issuer → https:\/\/hub\.example\.com/,
|
|
1649
|
+
);
|
|
1650
|
+
} finally {
|
|
1651
|
+
h.cleanup();
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
test("start hub: skips operator-token self-heal when no hub origin is resolvable", async () => {
|
|
1656
|
+
const h = makeHarness();
|
|
1657
|
+
try {
|
|
1658
|
+
let called = false;
|
|
1659
|
+
// No hubOrigin override, no expose-state, no hub.port file → resolveHubOrigin
|
|
1660
|
+
// yields undefined, so the self-heal seam must NOT be called.
|
|
1661
|
+
const code = await start("hub", {
|
|
1662
|
+
configDir: h.configDir,
|
|
1663
|
+
manifestPath: h.manifestPath,
|
|
1664
|
+
hub: {
|
|
1665
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1666
|
+
selfHealOperatorToken: async () => {
|
|
1667
|
+
called = true;
|
|
1668
|
+
return { kind: "absent" };
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
log: () => {},
|
|
1672
|
+
});
|
|
1673
|
+
expect(code).toBe(0);
|
|
1674
|
+
expect(called).toBe(false);
|
|
1675
|
+
} finally {
|
|
1676
|
+
h.cleanup();
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
test("start hub: a thrown error inside operator-token self-heal does NOT fail start", async () => {
|
|
1681
|
+
const h = makeHarness();
|
|
1682
|
+
try {
|
|
1683
|
+
const log: string[] = [];
|
|
1684
|
+
const code = await start("hub", {
|
|
1685
|
+
configDir: h.configDir,
|
|
1686
|
+
manifestPath: h.manifestPath,
|
|
1687
|
+
hubOrigin: "https://hub.example.com",
|
|
1688
|
+
hub: {
|
|
1689
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1690
|
+
selfHealOperatorToken: async () => {
|
|
1691
|
+
throw new Error("hub.db is locked");
|
|
1692
|
+
},
|
|
1693
|
+
},
|
|
1694
|
+
log: (l) => log.push(l),
|
|
1695
|
+
});
|
|
1696
|
+
expect(code).toBe(0);
|
|
1697
|
+
// Degrades to a brief note, not a hard failure.
|
|
1698
|
+
expect(log.join("\n")).toMatch(
|
|
1699
|
+
/operator\.token issuer self-heal skipped \(hub\.db is locked\)/,
|
|
1700
|
+
);
|
|
1701
|
+
} finally {
|
|
1702
|
+
h.cleanup();
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test("start hub: real self-heal re-mints a stale-iss operator token on disk", async () => {
|
|
1707
|
+
const h = makeHarness();
|
|
1708
|
+
try {
|
|
1709
|
+
// Seed signing keys + a stale-iss operator token in the harness configDir's
|
|
1710
|
+
// hub.db / operator.token, then drive the production self-heal seam.
|
|
1711
|
+
const db = openHubDb(hubDbPath(h.configDir));
|
|
1712
|
+
try {
|
|
1713
|
+
rotateSigningKey(db);
|
|
1714
|
+
await issueOperatorToken(db, "user-abc", {
|
|
1715
|
+
dir: h.configDir,
|
|
1716
|
+
issuer: "http://127.0.0.1:1939",
|
|
1717
|
+
scopeSet: "start",
|
|
1718
|
+
});
|
|
1719
|
+
} finally {
|
|
1720
|
+
db.close();
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const log: string[] = [];
|
|
1724
|
+
const code = await start("hub", {
|
|
1725
|
+
configDir: h.configDir,
|
|
1726
|
+
manifestPath: h.manifestPath,
|
|
1727
|
+
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
1728
|
+
// No selfHealOperatorToken override → exercises defaultSelfHealOperatorToken
|
|
1729
|
+
// (opens hub.db at <configDir>/hub.db).
|
|
1730
|
+
hub: {
|
|
1731
|
+
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1732
|
+
},
|
|
1733
|
+
log: (l) => log.push(l),
|
|
1734
|
+
});
|
|
1735
|
+
expect(code).toBe(0);
|
|
1736
|
+
expect(log.join("\n")).toMatch(
|
|
1737
|
+
/refreshed operator\.token issuer → https:\/\/gitcoin-parachute\.unforced\.dev/,
|
|
1738
|
+
);
|
|
1739
|
+
|
|
1740
|
+
// The on-disk token now validates under the new issuer, scope-set preserved.
|
|
1741
|
+
const verifyDb = openHubDb(hubDbPath(h.configDir));
|
|
1742
|
+
try {
|
|
1743
|
+
const onDisk = await readOperatorTokenFile(h.configDir);
|
|
1744
|
+
expect(onDisk).not.toBeNull();
|
|
1745
|
+
const validated = await validateAccessToken(
|
|
1746
|
+
verifyDb,
|
|
1747
|
+
onDisk as string,
|
|
1748
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
1749
|
+
);
|
|
1750
|
+
expect(validated.payload.iss).toBe("https://gitcoin-parachute.unforced.dev");
|
|
1751
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
1752
|
+
} finally {
|
|
1753
|
+
verifyDb.close();
|
|
1754
|
+
}
|
|
1755
|
+
} finally {
|
|
1756
|
+
h.cleanup();
|
|
1757
|
+
}
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1298
1760
|
test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
|
|
1299
1761
|
const h = makeHarness();
|
|
1300
1762
|
try {
|