@openparachute/hub 0.7.0 → 0.7.2-rc.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 +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
|
@@ -1219,6 +1219,282 @@ describe("parachute upgrade", () => {
|
|
|
1219
1219
|
}
|
|
1220
1220
|
});
|
|
1221
1221
|
|
|
1222
|
+
// -------------------------------------------------------------------------
|
|
1223
|
+
// hub#659 — rc channel resolves best-of @rc / @latest above installed.
|
|
1224
|
+
// The rc channel is a canary: it must stay ahead when a newer rc exists, but
|
|
1225
|
+
// CONVERGE to stable when a train shipped stable-direct (no rc cut) and the
|
|
1226
|
+
// box would otherwise strand below @latest with no visible path forward.
|
|
1227
|
+
// -------------------------------------------------------------------------
|
|
1228
|
+
|
|
1229
|
+
test("rc channel, newer rc exists → takes @rc (canary stays ahead, #332 unchanged)", async () => {
|
|
1230
|
+
const h = makeHarness();
|
|
1231
|
+
try {
|
|
1232
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1233
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.5-rc.8" });
|
|
1234
|
+
|
|
1235
|
+
const seenCmd: string[][] = [];
|
|
1236
|
+
const runner: UpgradeRunner = {
|
|
1237
|
+
async run(cmd) {
|
|
1238
|
+
seenCmd.push([...cmd]);
|
|
1239
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
1240
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.5-rc.9" });
|
|
1241
|
+
}
|
|
1242
|
+
return 0;
|
|
1243
|
+
},
|
|
1244
|
+
async capture(cmd) {
|
|
1245
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1246
|
+
return { code: 128, stdout: "" };
|
|
1247
|
+
}
|
|
1248
|
+
return { code: 0, stdout: "" };
|
|
1249
|
+
},
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
const logs: string[] = [];
|
|
1253
|
+
const code = await upgrade("hub", {
|
|
1254
|
+
supervisor: okHubUnitSupervisor,
|
|
1255
|
+
manifestPath: h.manifestPath,
|
|
1256
|
+
configDir: h.configDir,
|
|
1257
|
+
runner,
|
|
1258
|
+
findGlobalInstall: (pkg) =>
|
|
1259
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
1260
|
+
restartFn: async () => 0,
|
|
1261
|
+
// A newer rc (rc.9) exists AND @latest moved (0.7.1) — the canary wins.
|
|
1262
|
+
resolveChannelVersion: async (_pkg, channel) => (channel === "rc" ? "0.6.5-rc.9" : "0.7.1"),
|
|
1263
|
+
log: (l) => logs.push(l),
|
|
1264
|
+
});
|
|
1265
|
+
expect(code).toBe(0);
|
|
1266
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
1267
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
|
|
1268
|
+
// No "converging to stable" line — we stayed on the canary.
|
|
1269
|
+
expect(logs.join("\n")).not.toMatch(/converging to stable/);
|
|
1270
|
+
} finally {
|
|
1271
|
+
h.cleanup();
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
test("THE hub#659 pin: rc channel, NO newer rc but @latest is higher → converges to @latest with a loud line", async () => {
|
|
1276
|
+
// The live stranding case: friends.parachute.computer on 0.6.5-rc.8. The
|
|
1277
|
+
// @rc dist-tag never advanced (every train since shipped stable-direct), so
|
|
1278
|
+
// pre-fix `parachute upgrade hub` followed @rc and NO-OPPED — the box sat
|
|
1279
|
+
// below @latest (0.7.1) with no visible path. Post-fix: converge to the
|
|
1280
|
+
// concrete stable, pinned, with a loud log naming the channel + the version.
|
|
1281
|
+
const h = makeHarness();
|
|
1282
|
+
try {
|
|
1283
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1284
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.5-rc.8" });
|
|
1285
|
+
|
|
1286
|
+
const seenCmd: string[][] = [];
|
|
1287
|
+
const runner: UpgradeRunner = {
|
|
1288
|
+
async run(cmd) {
|
|
1289
|
+
seenCmd.push([...cmd]);
|
|
1290
|
+
// Honest registry simulation: `bun add -g @…@rc` resolves to the
|
|
1291
|
+
// installed rc.8 (no change → the pre-fix no-op); only the converged
|
|
1292
|
+
// stable pin rewrites the package.json. This is what makes the test
|
|
1293
|
+
// fail PRE-FIX — pre-fix the verb only ever asks for @rc and no-ops.
|
|
1294
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
1295
|
+
const spec = cmd[3] ?? "";
|
|
1296
|
+
if (spec.endsWith("@0.7.1") || spec.endsWith("@latest")) {
|
|
1297
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.7.1" });
|
|
1298
|
+
}
|
|
1299
|
+
// spec ending in @rc → resolves to installed 0.6.5-rc.8, no rewrite.
|
|
1300
|
+
}
|
|
1301
|
+
return 0;
|
|
1302
|
+
},
|
|
1303
|
+
async capture(cmd) {
|
|
1304
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1305
|
+
return { code: 128, stdout: "" };
|
|
1306
|
+
}
|
|
1307
|
+
return { code: 0, stdout: "" };
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
const logs: string[] = [];
|
|
1312
|
+
let restartedShort: string | undefined;
|
|
1313
|
+
const code = await upgrade("hub", {
|
|
1314
|
+
supervisor: okHubUnitSupervisor,
|
|
1315
|
+
manifestPath: h.manifestPath,
|
|
1316
|
+
configDir: h.configDir,
|
|
1317
|
+
runner,
|
|
1318
|
+
findGlobalInstall: (pkg) =>
|
|
1319
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
1320
|
+
restartFn: async (svc) => {
|
|
1321
|
+
restartedShort = svc;
|
|
1322
|
+
return 0;
|
|
1323
|
+
},
|
|
1324
|
+
// @rc stuck at the installed version (no newer rc); @latest moved ahead.
|
|
1325
|
+
resolveChannelVersion: async (_pkg, channel) => (channel === "rc" ? "0.6.5-rc.8" : "0.7.1"),
|
|
1326
|
+
log: (l) => logs.push(l),
|
|
1327
|
+
});
|
|
1328
|
+
expect(code).toBe(0);
|
|
1329
|
+
// Converged: pinned to the CONCRETE stable version (not @latest dist-tag),
|
|
1330
|
+
// so a moving tag can't race the resolution.
|
|
1331
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
1332
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@0.7.1"]);
|
|
1333
|
+
// Phase 5b: hub restarts via the manager (okHubUnitSupervisor); the verb
|
|
1334
|
+
// still reaches the restart leg because the version changed.
|
|
1335
|
+
const joined = logs.join("\n");
|
|
1336
|
+
// The LOUD convergence line — names the rc channel, the installed version,
|
|
1337
|
+
// the stable target, and the "next rc" reassurance.
|
|
1338
|
+
expect(joined).toMatch(/the rc channel has nothing newer than 0\.6\.5-rc\.8/);
|
|
1339
|
+
expect(joined).toMatch(/converging to stable 0\.7\.1/);
|
|
1340
|
+
expect(joined).toMatch(/pick up the next rc/);
|
|
1341
|
+
// It actually moved (the no-op would NOT have).
|
|
1342
|
+
expect(joined).toMatch(/0\.6\.5-rc\.8 → 0\.7\.1/);
|
|
1343
|
+
void restartedShort; // hub restarts via the manager seam, not restartFn
|
|
1344
|
+
} finally {
|
|
1345
|
+
h.cleanup();
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
test("rc channel, @rc and @latest both ≤ installed → up-to-date no-op (names both)", async () => {
|
|
1350
|
+
const h = makeHarness();
|
|
1351
|
+
try {
|
|
1352
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1353
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.7.2-rc.1" });
|
|
1354
|
+
|
|
1355
|
+
const seenCmd: string[][] = [];
|
|
1356
|
+
const runner: UpgradeRunner = {
|
|
1357
|
+
async run(cmd) {
|
|
1358
|
+
seenCmd.push([...cmd]);
|
|
1359
|
+
// bun add -g @rc resolves to the same installed version → no rewrite.
|
|
1360
|
+
return 0;
|
|
1361
|
+
},
|
|
1362
|
+
async capture(cmd) {
|
|
1363
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1364
|
+
return { code: 128, stdout: "" };
|
|
1365
|
+
}
|
|
1366
|
+
return { code: 0, stdout: "" };
|
|
1367
|
+
},
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
const logs: string[] = [];
|
|
1371
|
+
let restartCalled = false;
|
|
1372
|
+
const code = await upgrade("hub", {
|
|
1373
|
+
supervisor: okHubUnitSupervisor,
|
|
1374
|
+
manifestPath: h.manifestPath,
|
|
1375
|
+
configDir: h.configDir,
|
|
1376
|
+
runner,
|
|
1377
|
+
findGlobalInstall: (pkg) =>
|
|
1378
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
1379
|
+
restartFn: async () => {
|
|
1380
|
+
restartCalled = true;
|
|
1381
|
+
return 0;
|
|
1382
|
+
},
|
|
1383
|
+
// Nothing newer anywhere: @rc == installed, @latest below installed.
|
|
1384
|
+
resolveChannelVersion: async (_pkg, channel) => (channel === "rc" ? "0.7.2-rc.1" : "0.7.1"),
|
|
1385
|
+
log: (l) => logs.push(l),
|
|
1386
|
+
});
|
|
1387
|
+
expect(code).toBe(0);
|
|
1388
|
+
expect(restartCalled).toBe(false);
|
|
1389
|
+
// Stays on @rc (no convergence); the post-install no-op fires.
|
|
1390
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
1391
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
|
|
1392
|
+
const joined = logs.join("\n");
|
|
1393
|
+
expect(joined).toMatch(/@rc \(0\.7\.2-rc\.1\) and @latest \(0\.7\.1\) are both at or below/);
|
|
1394
|
+
expect(joined).toMatch(/already at 0\.7\.2-rc\.1/);
|
|
1395
|
+
expect(joined).not.toMatch(/converging to stable/);
|
|
1396
|
+
} finally {
|
|
1397
|
+
h.cleanup();
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test("latest channel is unchanged: stable install never reaches for @rc (hub#659 scoped to rc)", async () => {
|
|
1402
|
+
const h = makeHarness();
|
|
1403
|
+
try {
|
|
1404
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1405
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.7.1" });
|
|
1406
|
+
|
|
1407
|
+
const seenCmd: string[][] = [];
|
|
1408
|
+
let rcProbed = false;
|
|
1409
|
+
const runner: UpgradeRunner = {
|
|
1410
|
+
async run(cmd) {
|
|
1411
|
+
seenCmd.push([...cmd]);
|
|
1412
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
1413
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.7.2" });
|
|
1414
|
+
}
|
|
1415
|
+
return 0;
|
|
1416
|
+
},
|
|
1417
|
+
async capture(cmd) {
|
|
1418
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1419
|
+
return { code: 128, stdout: "" };
|
|
1420
|
+
}
|
|
1421
|
+
return { code: 0, stdout: "" };
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
const code = await upgrade("hub", {
|
|
1426
|
+
supervisor: okHubUnitSupervisor,
|
|
1427
|
+
manifestPath: h.manifestPath,
|
|
1428
|
+
configDir: h.configDir,
|
|
1429
|
+
runner,
|
|
1430
|
+
findGlobalInstall: (pkg) =>
|
|
1431
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
1432
|
+
restartFn: async () => 0,
|
|
1433
|
+
// Track whether the rc tag is ever probed — it must NOT be on the
|
|
1434
|
+
// stable channel (best-of resolution is scoped to rc).
|
|
1435
|
+
resolveChannelVersion: async (_pkg, channel) => {
|
|
1436
|
+
if (channel === "rc") rcProbed = true;
|
|
1437
|
+
return channel === "latest" ? "0.7.2" : "0.8.0-rc.1";
|
|
1438
|
+
},
|
|
1439
|
+
log: () => {},
|
|
1440
|
+
});
|
|
1441
|
+
expect(code).toBe(0);
|
|
1442
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
1443
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@latest"]);
|
|
1444
|
+
// The stable channel never reaches across to @rc.
|
|
1445
|
+
expect(rcProbed).toBe(false);
|
|
1446
|
+
} finally {
|
|
1447
|
+
h.cleanup();
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("rc channel converge still respects the downgrade guard (no @latest below installed sneaks through)", async () => {
|
|
1452
|
+
// Defensive: if @rc is stuck AND @latest is somehow BELOW installed (an
|
|
1453
|
+
// operator on a hand-pinned newer rc than the latest stable), we neither
|
|
1454
|
+
// converge nor downgrade — we stay on @rc and no-op.
|
|
1455
|
+
const h = makeHarness();
|
|
1456
|
+
try {
|
|
1457
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1458
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.8.0-rc.1" });
|
|
1459
|
+
|
|
1460
|
+
const seenCmd: string[][] = [];
|
|
1461
|
+
const runner: UpgradeRunner = {
|
|
1462
|
+
async run(cmd) {
|
|
1463
|
+
seenCmd.push([...cmd]);
|
|
1464
|
+
return 0;
|
|
1465
|
+
},
|
|
1466
|
+
async capture(cmd) {
|
|
1467
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1468
|
+
return { code: 128, stdout: "" };
|
|
1469
|
+
}
|
|
1470
|
+
return { code: 0, stdout: "" };
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
const logs: string[] = [];
|
|
1475
|
+
const code = await upgrade("hub", {
|
|
1476
|
+
supervisor: okHubUnitSupervisor,
|
|
1477
|
+
manifestPath: h.manifestPath,
|
|
1478
|
+
configDir: h.configDir,
|
|
1479
|
+
runner,
|
|
1480
|
+
findGlobalInstall: (pkg) =>
|
|
1481
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
1482
|
+
restartFn: async () => 0,
|
|
1483
|
+
// @rc == installed (no newer rc); @latest is a PRIOR stable, below us.
|
|
1484
|
+
resolveChannelVersion: async (_pkg, channel) => (channel === "rc" ? "0.8.0-rc.1" : "0.7.1"),
|
|
1485
|
+
log: (l) => logs.push(l),
|
|
1486
|
+
});
|
|
1487
|
+
expect(code).toBe(0);
|
|
1488
|
+
// Stayed on @rc — no convergence to a lower stable.
|
|
1489
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
1490
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
|
|
1491
|
+
expect(logs.join("\n")).not.toMatch(/converging to stable/);
|
|
1492
|
+
expect(logs.join("\n")).not.toMatch(/refusing to downgrade/);
|
|
1493
|
+
} finally {
|
|
1494
|
+
h.cleanup();
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1222
1498
|
test("--tag still overrides everything (back-compat for programmatic pin)", async () => {
|
|
1223
1499
|
const h = makeHarness();
|
|
1224
1500
|
try {
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { vaultRemove } from "../commands/vault-remove.ts";
|
|
3
|
+
|
|
4
|
+
const BEARER = "header.payload.signature";
|
|
5
|
+
|
|
6
|
+
interface FakeCall {
|
|
7
|
+
url: string;
|
|
8
|
+
method: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fake `fetch` that records each call and returns canned responses in sequence.
|
|
15
|
+
* Lets a test assert method / path / body / auth header without a real socket.
|
|
16
|
+
*/
|
|
17
|
+
function fakeFetch(responses: Array<{ status: number; body: unknown }>): {
|
|
18
|
+
fetch: typeof fetch;
|
|
19
|
+
calls: FakeCall[];
|
|
20
|
+
} {
|
|
21
|
+
const calls: FakeCall[] = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
const f = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
24
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
25
|
+
const headers: Record<string, string> = {};
|
|
26
|
+
if (init?.headers) {
|
|
27
|
+
for (const [k, v] of Object.entries(init.headers as Record<string, string>)) {
|
|
28
|
+
headers[k.toLowerCase()] = v;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const call: FakeCall = { url, method: init?.method ?? "GET", headers };
|
|
32
|
+
if (typeof init?.body === "string") call.body = init.body;
|
|
33
|
+
calls.push(call);
|
|
34
|
+
const r = responses[Math.min(i, responses.length - 1)];
|
|
35
|
+
i++;
|
|
36
|
+
return new Response(JSON.stringify(r?.body ?? {}), {
|
|
37
|
+
status: r?.status ?? 200,
|
|
38
|
+
headers: { "content-type": "application/json" },
|
|
39
|
+
});
|
|
40
|
+
}) as unknown as typeof fetch;
|
|
41
|
+
return { fetch: f, calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Collect log + error output for assertions. */
|
|
45
|
+
function makeSinks() {
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
const err: string[] = [];
|
|
48
|
+
return {
|
|
49
|
+
out,
|
|
50
|
+
err,
|
|
51
|
+
log: (l: string) => out.push(l),
|
|
52
|
+
logError: (l: string) => err.push(l),
|
|
53
|
+
text: () => out.join("\n"),
|
|
54
|
+
errText: () => err.join("\n"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Spy that fails the test if `Bun.spawn` is ever invoked. The 409 guardrail is
|
|
60
|
+
* "never spawn `parachute-vault`" — this proves it at the runtime boundary, not
|
|
61
|
+
* just by reading the fetch log.
|
|
62
|
+
*/
|
|
63
|
+
function withSpawnSpy<T>(fn: (spawned: { count: number }) => Promise<T>): Promise<T> {
|
|
64
|
+
const original = Bun.spawn;
|
|
65
|
+
const spawned = { count: 0 };
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only monkeypatch of Bun.spawn.
|
|
67
|
+
(Bun as any).spawn = (...a: unknown[]) => {
|
|
68
|
+
spawned.count++;
|
|
69
|
+
return (original as unknown as (...args: unknown[]) => unknown)(...a);
|
|
70
|
+
};
|
|
71
|
+
return fn(spawned).finally(() => {
|
|
72
|
+
// biome-ignore lint/suspicious/noExplicitAny: restore the original.
|
|
73
|
+
(Bun as any).spawn = original;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const SUCCESS_BODY = {
|
|
78
|
+
ok: true,
|
|
79
|
+
name: "scratch",
|
|
80
|
+
cascade: {
|
|
81
|
+
tokens_revoked: 3,
|
|
82
|
+
grants_rewritten: 1,
|
|
83
|
+
grants_dropped: 2,
|
|
84
|
+
user_vaults_removed: 4,
|
|
85
|
+
invites_invalidated: 1,
|
|
86
|
+
connections_torn_down: 1,
|
|
87
|
+
orphaned_channels: [],
|
|
88
|
+
vault_removed: true,
|
|
89
|
+
module_restarted: true,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
describe("vaultRemove — request shape", () => {
|
|
94
|
+
test("sends DELETE /vaults/<name> with the confirm body + operator bearer", async () => {
|
|
95
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
96
|
+
const sinks = makeSinks();
|
|
97
|
+
const code = await vaultRemove(["scratch"], {
|
|
98
|
+
resolveBearer: async () => BEARER,
|
|
99
|
+
fetch,
|
|
100
|
+
baseUrl: "http://127.0.0.1:1939",
|
|
101
|
+
log: sinks.log,
|
|
102
|
+
logError: sinks.logError,
|
|
103
|
+
});
|
|
104
|
+
expect(code).toBe(0);
|
|
105
|
+
expect(calls).toHaveLength(1);
|
|
106
|
+
const call = calls[0];
|
|
107
|
+
expect(call?.method).toBe("DELETE");
|
|
108
|
+
expect(call?.url).toBe("http://127.0.0.1:1939/vaults/scratch");
|
|
109
|
+
expect(call?.headers.authorization).toBe(`Bearer ${BEARER}`);
|
|
110
|
+
expect(call?.headers["content-type"]).toBe("application/json");
|
|
111
|
+
expect(JSON.parse(call?.body ?? "{}")).toEqual({ confirm: "scratch" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("URL-encodes the vault name in the path", async () => {
|
|
115
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
116
|
+
const sinks = makeSinks();
|
|
117
|
+
await vaultRemove(["my vault"], {
|
|
118
|
+
resolveBearer: async () => BEARER,
|
|
119
|
+
fetch,
|
|
120
|
+
log: sinks.log,
|
|
121
|
+
logError: sinks.logError,
|
|
122
|
+
});
|
|
123
|
+
expect(calls[0]?.url).toContain("/vaults/my%20vault");
|
|
124
|
+
// The confirm body carries the RAW name (matches the endpoint's check).
|
|
125
|
+
expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ confirm: "my vault" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("--yes is accepted and does not change behaviour (no extra prompt)", async () => {
|
|
129
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
130
|
+
const sinks = makeSinks();
|
|
131
|
+
const code = await vaultRemove(["scratch", "--yes"], {
|
|
132
|
+
resolveBearer: async () => BEARER,
|
|
133
|
+
fetch,
|
|
134
|
+
log: sinks.log,
|
|
135
|
+
logError: sinks.logError,
|
|
136
|
+
});
|
|
137
|
+
expect(code).toBe(0);
|
|
138
|
+
expect(calls).toHaveLength(1);
|
|
139
|
+
expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ confirm: "scratch" });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("vaultRemove — 200 success", () => {
|
|
144
|
+
test("renders the cascade summary and returns 0", async () => {
|
|
145
|
+
const { fetch } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
146
|
+
const sinks = makeSinks();
|
|
147
|
+
const code = await vaultRemove(["scratch"], {
|
|
148
|
+
resolveBearer: async () => BEARER,
|
|
149
|
+
fetch,
|
|
150
|
+
log: sinks.log,
|
|
151
|
+
logError: sinks.logError,
|
|
152
|
+
});
|
|
153
|
+
expect(code).toBe(0);
|
|
154
|
+
const text = sinks.text();
|
|
155
|
+
expect(text).toContain("tokens revoked:");
|
|
156
|
+
expect(text).toContain("3");
|
|
157
|
+
expect(text).toContain("user_vaults removed:");
|
|
158
|
+
expect(text).toContain("4");
|
|
159
|
+
expect(text).toContain("vault removed:");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("warns about orphaned_channels and prints warnings[]", async () => {
|
|
163
|
+
const body = {
|
|
164
|
+
ok: true,
|
|
165
|
+
name: "scratch",
|
|
166
|
+
cascade: {
|
|
167
|
+
tokens_revoked: 0,
|
|
168
|
+
grants_rewritten: 0,
|
|
169
|
+
grants_dropped: 0,
|
|
170
|
+
user_vaults_removed: 0,
|
|
171
|
+
invites_invalidated: 0,
|
|
172
|
+
connections_torn_down: 0,
|
|
173
|
+
orphaned_channels: ["telegram-main", "sms-alerts"],
|
|
174
|
+
vault_removed: true,
|
|
175
|
+
module_restarted: false,
|
|
176
|
+
},
|
|
177
|
+
warnings: [{ step: "module_restart", detail: "no supervisor available" }],
|
|
178
|
+
};
|
|
179
|
+
const { fetch } = fakeFetch([{ status: 200, body }]);
|
|
180
|
+
const sinks = makeSinks();
|
|
181
|
+
const code = await vaultRemove(["scratch"], {
|
|
182
|
+
resolveBearer: async () => BEARER,
|
|
183
|
+
fetch,
|
|
184
|
+
log: sinks.log,
|
|
185
|
+
logError: sinks.logError,
|
|
186
|
+
});
|
|
187
|
+
expect(code).toBe(0);
|
|
188
|
+
const text = sinks.text();
|
|
189
|
+
expect(text).toContain("telegram-main");
|
|
190
|
+
expect(text).toContain("sms-alerts");
|
|
191
|
+
expect(text).toContain("agent UI");
|
|
192
|
+
expect(text).toContain("module_restart");
|
|
193
|
+
expect(text).toContain("no supervisor available");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
|
|
198
|
+
test("returns NON-ZERO and NEVER spawns parachute-vault", async () => {
|
|
199
|
+
await withSpawnSpy(async (spawned) => {
|
|
200
|
+
const { fetch, calls } = fakeFetch([
|
|
201
|
+
{
|
|
202
|
+
status: 409,
|
|
203
|
+
body: {
|
|
204
|
+
error: "last_vault",
|
|
205
|
+
error_description:
|
|
206
|
+
'"scratch" is the last vault on this hub. Create another vault first, or use the CLI.',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
const sinks = makeSinks();
|
|
211
|
+
const code = await vaultRemove(["scratch"], {
|
|
212
|
+
resolveBearer: async () => BEARER,
|
|
213
|
+
fetch,
|
|
214
|
+
log: sinks.log,
|
|
215
|
+
logError: sinks.logError,
|
|
216
|
+
});
|
|
217
|
+
// Non-zero exit.
|
|
218
|
+
expect(code).not.toBe(0);
|
|
219
|
+
// Exactly ONE fetch (the DELETE) — no fall-through retry path.
|
|
220
|
+
expect(calls).toHaveLength(1);
|
|
221
|
+
expect(calls[0]?.method).toBe("DELETE");
|
|
222
|
+
// The load-bearing invariant: no `parachute-vault` spawn.
|
|
223
|
+
expect(spawned.count).toBe(0);
|
|
224
|
+
// Surfaces the endpoint message + the cascade-skip warning on the escape hatch.
|
|
225
|
+
const errText = sinks.errText();
|
|
226
|
+
expect(errText).toContain("last vault");
|
|
227
|
+
expect(errText).toContain("SKIPS the identity cascade");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("vaultRemove — 404 not_found (idempotent)", () => {
|
|
233
|
+
test("renders a clean 'already removed' message and returns 0", async () => {
|
|
234
|
+
const { fetch } = fakeFetch([
|
|
235
|
+
{
|
|
236
|
+
status: 404,
|
|
237
|
+
body: { error: "not_found", error_description: 'no vault named "ghost" on this hub' },
|
|
238
|
+
},
|
|
239
|
+
]);
|
|
240
|
+
const sinks = makeSinks();
|
|
241
|
+
const code = await vaultRemove(["ghost"], {
|
|
242
|
+
resolveBearer: async () => BEARER,
|
|
243
|
+
fetch,
|
|
244
|
+
log: sinks.log,
|
|
245
|
+
logError: sinks.logError,
|
|
246
|
+
});
|
|
247
|
+
expect(code).toBe(0);
|
|
248
|
+
const text = sinks.text();
|
|
249
|
+
expect(text.toLowerCase()).toContain("already removed");
|
|
250
|
+
// Not scary — no "error" framing in the error sink.
|
|
251
|
+
expect(sinks.errText()).toBe("");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("vaultRemove — 400 confirm_mismatch", () => {
|
|
256
|
+
test("passes the hub message through and returns non-zero", async () => {
|
|
257
|
+
const { fetch } = fakeFetch([
|
|
258
|
+
{
|
|
259
|
+
status: 400,
|
|
260
|
+
body: {
|
|
261
|
+
error: "confirm_mismatch",
|
|
262
|
+
error_description: 'deleting a vault requires the body {"confirm": "scratch"}',
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
]);
|
|
266
|
+
const sinks = makeSinks();
|
|
267
|
+
const code = await vaultRemove(["scratch"], {
|
|
268
|
+
resolveBearer: async () => BEARER,
|
|
269
|
+
fetch,
|
|
270
|
+
log: sinks.log,
|
|
271
|
+
logError: sinks.logError,
|
|
272
|
+
});
|
|
273
|
+
expect(code).not.toBe(0);
|
|
274
|
+
expect(sinks.errText()).toContain('{"confirm": "scratch"}');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("vaultRemove — arg validation", () => {
|
|
279
|
+
test("missing name → error + non-zero, no fetch", async () => {
|
|
280
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
281
|
+
const sinks = makeSinks();
|
|
282
|
+
const code = await vaultRemove([], {
|
|
283
|
+
resolveBearer: async () => BEARER,
|
|
284
|
+
fetch,
|
|
285
|
+
log: sinks.log,
|
|
286
|
+
logError: sinks.logError,
|
|
287
|
+
});
|
|
288
|
+
expect(code).not.toBe(0);
|
|
289
|
+
expect(calls).toHaveLength(0);
|
|
290
|
+
expect(sinks.errText().toLowerCase()).toContain("vault name is required");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("unknown flag → error + non-zero, no fetch", async () => {
|
|
294
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
295
|
+
const sinks = makeSinks();
|
|
296
|
+
const code = await vaultRemove(["scratch", "--force"], {
|
|
297
|
+
resolveBearer: async () => BEARER,
|
|
298
|
+
fetch,
|
|
299
|
+
log: sinks.log,
|
|
300
|
+
logError: sinks.logError,
|
|
301
|
+
});
|
|
302
|
+
expect(code).not.toBe(0);
|
|
303
|
+
expect(calls).toHaveLength(0);
|
|
304
|
+
expect(sinks.errText()).toContain("unknown flag");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("vaultRemove — missing operator token", () => {
|
|
309
|
+
test("actionable error, no DELETE sent", async () => {
|
|
310
|
+
const { NoOperatorTokenError } = await import("../commands/vault-remove.ts");
|
|
311
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
312
|
+
const sinks = makeSinks();
|
|
313
|
+
const code = await vaultRemove(["scratch"], {
|
|
314
|
+
resolveBearer: async () => {
|
|
315
|
+
throw new NoOperatorTokenError();
|
|
316
|
+
},
|
|
317
|
+
fetch,
|
|
318
|
+
log: sinks.log,
|
|
319
|
+
logError: sinks.logError,
|
|
320
|
+
});
|
|
321
|
+
expect(code).not.toBe(0);
|
|
322
|
+
expect(calls).toHaveLength(0);
|
|
323
|
+
expect(sinks.errText()).toContain("rotate-operator");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("expired operator token → same actionable error, no DELETE sent", async () => {
|
|
327
|
+
const { OperatorTokenExpiredError } = await import("../commands/vault-remove.ts");
|
|
328
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
329
|
+
const sinks = makeSinks();
|
|
330
|
+
const code = await vaultRemove(["scratch"], {
|
|
331
|
+
resolveBearer: async () => {
|
|
332
|
+
// The real error carries its actionable message at the throw site; assert
|
|
333
|
+
// the handler surfaces it verbatim (not a raw 401).
|
|
334
|
+
throw new OperatorTokenExpiredError(
|
|
335
|
+
"operator token expired — run `parachute auth rotate-operator`",
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
fetch,
|
|
339
|
+
log: sinks.log,
|
|
340
|
+
logError: sinks.logError,
|
|
341
|
+
});
|
|
342
|
+
expect(code).not.toBe(0);
|
|
343
|
+
expect(calls).toHaveLength(0);
|
|
344
|
+
expect(sinks.errText()).toContain("rotate-operator");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("vaultRemove — --hub-origin override", () => {
|
|
349
|
+
test("--hub-origin <url> targets the given hub base", async () => {
|
|
350
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
351
|
+
const sinks = makeSinks();
|
|
352
|
+
const code = await vaultRemove(["scratch", "--hub-origin", "http://127.0.0.1:19390"], {
|
|
353
|
+
resolveBearer: async () => BEARER,
|
|
354
|
+
fetch,
|
|
355
|
+
log: sinks.log,
|
|
356
|
+
logError: sinks.logError,
|
|
357
|
+
});
|
|
358
|
+
expect(code).toBe(0);
|
|
359
|
+
expect(calls[0]?.url).toBe("http://127.0.0.1:19390/vaults/scratch");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("--hub-origin with no URL argument → error, no DELETE sent", async () => {
|
|
363
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: SUCCESS_BODY }]);
|
|
364
|
+
const sinks = makeSinks();
|
|
365
|
+
const code = await vaultRemove(["scratch", "--hub-origin"], {
|
|
366
|
+
resolveBearer: async () => BEARER,
|
|
367
|
+
fetch,
|
|
368
|
+
log: sinks.log,
|
|
369
|
+
logError: sinks.logError,
|
|
370
|
+
});
|
|
371
|
+
expect(code).not.toBe(0);
|
|
372
|
+
expect(calls).toHaveLength(0);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("vaultRemove — hub not running", () => {
|
|
377
|
+
test("ECONNREFUSED on loopback → actionable 'hub must be running', non-zero", async () => {
|
|
378
|
+
const f = (async () => {
|
|
379
|
+
throw new Error("connect ECONNREFUSED 127.0.0.1:1939");
|
|
380
|
+
}) as unknown as typeof fetch;
|
|
381
|
+
const sinks = makeSinks();
|
|
382
|
+
const code = await vaultRemove(["scratch"], {
|
|
383
|
+
resolveBearer: async () => BEARER,
|
|
384
|
+
fetch: f,
|
|
385
|
+
log: sinks.log,
|
|
386
|
+
logError: sinks.logError,
|
|
387
|
+
});
|
|
388
|
+
expect(code).not.toBe(0);
|
|
389
|
+
const errText = sinks.errText();
|
|
390
|
+
expect(errText).toContain("hub must be running");
|
|
391
|
+
expect(errText).toContain("parachute start");
|
|
392
|
+
});
|
|
393
|
+
});
|