@openparachute/hub 0.6.2 → 0.6.3-rc.2

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.
Files changed (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -0,0 +1,556 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+ import {
7
+ DEFAULT_HUB_BASE_URL,
8
+ ModuleOpFailedError,
9
+ ModuleOpHttpError,
10
+ NoOperatorTokenError,
11
+ driveModuleOp,
12
+ fetchModuleLogs,
13
+ fetchModuleStates,
14
+ resolveOperatorBearer,
15
+ } from "../module-ops-client.ts";
16
+ import { issueOperatorToken } from "../operator-token.ts";
17
+ import { rotateSigningKey } from "../signing-keys.ts";
18
+ import { createUser } from "../users.ts";
19
+
20
+ const ISSUER = "http://127.0.0.1:1939";
21
+ /** 30d TTL so `useOperatorTokenWithAutoRotate` returns the token as-is (no rotation noise). */
22
+ const LONG_TTL_S = 30 * 24 * 60 * 60;
23
+
24
+ interface Harness {
25
+ dir: string;
26
+ db: ReturnType<typeof openHubDb>;
27
+ userId: string;
28
+ cleanup: () => void;
29
+ }
30
+
31
+ /** Harness WITH an operator.token already on disk. */
32
+ async function makeHarnessWithToken(): Promise<Harness> {
33
+ const dir = mkdtempSync(join(tmpdir(), "phub-module-ops-client-"));
34
+ const db = openHubDb(hubDbPath(dir));
35
+ rotateSigningKey(db);
36
+ const user = await createUser(db, "owner", "pw");
37
+ await issueOperatorToken(db, user.id, { dir, issuer: ISSUER, ttlSeconds: LONG_TTL_S });
38
+ return {
39
+ dir,
40
+ db,
41
+ userId: user.id,
42
+ cleanup: () => {
43
+ db.close();
44
+ rmSync(dir, { recursive: true, force: true });
45
+ },
46
+ };
47
+ }
48
+
49
+ /** Harness WITHOUT any operator.token on disk. */
50
+ async function makeHarnessNoToken(): Promise<Harness> {
51
+ const dir = mkdtempSync(join(tmpdir(), "phub-module-ops-client-notok-"));
52
+ const db = openHubDb(hubDbPath(dir));
53
+ rotateSigningKey(db);
54
+ const user = await createUser(db, "owner", "pw");
55
+ return {
56
+ dir,
57
+ db,
58
+ userId: user.id,
59
+ cleanup: () => {
60
+ db.close();
61
+ rmSync(dir, { recursive: true, force: true });
62
+ },
63
+ };
64
+ }
65
+
66
+ interface FakeCall {
67
+ url: string;
68
+ method: string;
69
+ headers: Record<string, string>;
70
+ body?: string;
71
+ }
72
+
73
+ /**
74
+ * Build a fake `fetch` that records every call and returns canned responses
75
+ * in sequence (one per call). The recorded calls let tests assert the bearer
76
+ * header + URL without a real socket.
77
+ */
78
+ function fakeFetch(responses: Array<{ status: number; body: unknown }>): {
79
+ fetch: typeof fetch;
80
+ calls: FakeCall[];
81
+ } {
82
+ const calls: FakeCall[] = [];
83
+ let i = 0;
84
+ const f = (async (input: string | URL | Request, init?: RequestInit) => {
85
+ const url = typeof input === "string" ? input : input.toString();
86
+ const headers: Record<string, string> = {};
87
+ if (init?.headers) {
88
+ for (const [k, v] of Object.entries(init.headers as Record<string, string>)) {
89
+ headers[k.toLowerCase()] = v;
90
+ }
91
+ }
92
+ const call: FakeCall = { url, method: init?.method ?? "GET", headers };
93
+ if (typeof init?.body === "string") call.body = init.body;
94
+ calls.push(call);
95
+ const r = responses[Math.min(i, responses.length - 1)];
96
+ i++;
97
+ return new Response(JSON.stringify(r?.body ?? {}), {
98
+ status: r?.status ?? 200,
99
+ headers: { "content-type": "application/json" },
100
+ });
101
+ }) as unknown as typeof fetch;
102
+ return { fetch: f, calls };
103
+ }
104
+
105
+ describe("resolveOperatorBearer", () => {
106
+ let h: Harness;
107
+ afterEach(() => h.cleanup());
108
+
109
+ test("reads operator.token from disk and returns it as the bearer", async () => {
110
+ h = await makeHarnessWithToken();
111
+ const bearer = await resolveOperatorBearer({ db: h.db, issuer: ISSUER, configDir: h.dir });
112
+ expect(typeof bearer).toBe("string");
113
+ expect(bearer.length).toBeGreaterThan(0);
114
+ // It's a JWT (three dot-separated segments).
115
+ expect(bearer.split(".")).toHaveLength(3);
116
+ });
117
+
118
+ test("no operator.token on disk → actionable NoOperatorTokenError", async () => {
119
+ h = await makeHarnessNoToken();
120
+ let err: unknown;
121
+ try {
122
+ await resolveOperatorBearer({ db: h.db, issuer: ISSUER, configDir: h.dir });
123
+ } catch (e) {
124
+ err = e;
125
+ }
126
+ expect(err).toBeInstanceOf(NoOperatorTokenError);
127
+ expect((err as Error).message).toContain("no operator token");
128
+ expect((err as Error).message).toContain("parachute auth rotate-operator");
129
+ });
130
+ });
131
+
132
+ describe("driveModuleOp — auth + transport", () => {
133
+ let h: Harness;
134
+ afterEach(() => h.cleanup());
135
+
136
+ test("presents the operator token as Authorization: Bearer to the module-op endpoint", async () => {
137
+ h = await makeHarnessWithToken();
138
+ const { fetch: f, calls } = fakeFetch([
139
+ { status: 200, body: { short: "vault", state: { status: "running" } } },
140
+ ]);
141
+ const res = await driveModuleOp("vault", "start", {
142
+ db: h.db,
143
+ issuer: ISSUER,
144
+ configDir: h.dir,
145
+ fetch: f,
146
+ });
147
+ expect(res.status).toBe(200);
148
+ expect(calls).toHaveLength(1);
149
+ expect(calls[0]?.url).toBe(`${DEFAULT_HUB_BASE_URL}/api/modules/vault/start`);
150
+ expect(calls[0]?.method).toBe("POST");
151
+ expect(calls[0]?.headers.authorization).toMatch(/^Bearer \S+\.\S+\.\S+$/);
152
+ });
153
+
154
+ test("honors an injected baseUrl", async () => {
155
+ h = await makeHarnessWithToken();
156
+ const { fetch: f, calls } = fakeFetch([{ status: 200, body: { stopped: true } }]);
157
+ await driveModuleOp("scribe", "stop", {
158
+ db: h.db,
159
+ issuer: ISSUER,
160
+ configDir: h.dir,
161
+ baseUrl: "http://127.0.0.1:1955/",
162
+ fetch: f,
163
+ });
164
+ expect(calls[0]?.url).toBe("http://127.0.0.1:1955/api/modules/scribe/stop");
165
+ });
166
+
167
+ test("no operator token → NoOperatorTokenError before any fetch", async () => {
168
+ h = await makeHarnessNoToken();
169
+ const { fetch: f, calls } = fakeFetch([{ status: 200, body: {} }]);
170
+ let err: unknown;
171
+ try {
172
+ await driveModuleOp("vault", "start", {
173
+ db: h.db,
174
+ issuer: ISSUER,
175
+ configDir: h.dir,
176
+ fetch: f,
177
+ });
178
+ } catch (e) {
179
+ err = e;
180
+ }
181
+ expect(err).toBeInstanceOf(NoOperatorTokenError);
182
+ // Never hit the network — the token gate fails first.
183
+ expect(calls).toHaveLength(0);
184
+ });
185
+
186
+ test("non-2xx hub response → ModuleOpHttpError carrying status + code", async () => {
187
+ h = await makeHarnessWithToken();
188
+ const { fetch: f } = fakeFetch([
189
+ {
190
+ status: 400,
191
+ body: { error: "not_installed", error_description: "vault is not installed" },
192
+ },
193
+ ]);
194
+ let err: unknown;
195
+ try {
196
+ await driveModuleOp("vault", "start", {
197
+ db: h.db,
198
+ issuer: ISSUER,
199
+ configDir: h.dir,
200
+ fetch: f,
201
+ });
202
+ } catch (e) {
203
+ err = e;
204
+ }
205
+ expect(err).toBeInstanceOf(ModuleOpHttpError);
206
+ expect((err as ModuleOpHttpError).status).toBe(400);
207
+ expect((err as ModuleOpHttpError).code).toBe("not_installed");
208
+ });
209
+
210
+ test("sync op (start) returns the hub body as-is — no operation poll", async () => {
211
+ h = await makeHarnessWithToken();
212
+ const { fetch: f, calls } = fakeFetch([
213
+ { status: 200, body: { short: "vault", state: { status: "running" } } },
214
+ ]);
215
+ const res = await driveModuleOp("vault", "start", {
216
+ db: h.db,
217
+ issuer: ISSUER,
218
+ configDir: h.dir,
219
+ fetch: f,
220
+ });
221
+ expect(res.operationId).toBeUndefined();
222
+ expect(res.body).toEqual({ short: "vault", state: { status: "running" } });
223
+ // Exactly one HTTP call — the POST; no follow-up GET poll.
224
+ expect(calls).toHaveLength(1);
225
+ });
226
+ });
227
+
228
+ describe("driveModuleOp — async operation polling", () => {
229
+ let h: Harness;
230
+ afterEach(() => h.cleanup());
231
+
232
+ test("202 + operation_id → polls GET /operations/:id to a succeeded terminal", async () => {
233
+ h = await makeHarnessWithToken();
234
+ const { fetch: f, calls } = fakeFetch([
235
+ { status: 202, body: { operation_id: "op-123" } },
236
+ { status: 200, body: { id: "op-123", status: "running" } },
237
+ { status: 200, body: { id: "op-123", status: "succeeded" } },
238
+ ]);
239
+ const res = await driveModuleOp("vault", "install", {
240
+ db: h.db,
241
+ issuer: ISSUER,
242
+ configDir: h.dir,
243
+ fetch: f,
244
+ sleep: async () => {},
245
+ });
246
+ expect(res.operationId).toBe("op-123");
247
+ expect((res.body as { status: string }).status).toBe("succeeded");
248
+ // POST + two polls.
249
+ expect(calls).toHaveLength(3);
250
+ expect(calls[1]?.url).toBe(`${DEFAULT_HUB_BASE_URL}/api/modules/operations/op-123`);
251
+ expect(calls[1]?.method).toBe("GET");
252
+ // Polls also present the bearer.
253
+ expect(calls[1]?.headers.authorization).toMatch(/^Bearer /);
254
+ });
255
+
256
+ test("operation reaches failed → ModuleOpFailedError carrying the op error", async () => {
257
+ h = await makeHarnessWithToken();
258
+ const { fetch: f } = fakeFetch([
259
+ { status: 202, body: { operation_id: "op-x" } },
260
+ { status: 200, body: { id: "op-x", status: "failed", error: "bun add -g exited 1" } },
261
+ ]);
262
+ let err: unknown;
263
+ try {
264
+ await driveModuleOp("vault", "install", {
265
+ db: h.db,
266
+ issuer: ISSUER,
267
+ configDir: h.dir,
268
+ fetch: f,
269
+ sleep: async () => {},
270
+ });
271
+ } catch (e) {
272
+ err = e;
273
+ }
274
+ expect(err).toBeInstanceOf(ModuleOpFailedError);
275
+ expect((err as Error).message).toBe("bun add -g exited 1");
276
+ });
277
+
278
+ test("poll timeout: a never-succeeding op rejects with ModuleOpFailedError (no silent 2min hang)", async () => {
279
+ h = await makeHarnessWithToken();
280
+ // POST returns 202 + operation_id; every GET poll returns an in-progress
281
+ // op that never reaches `succeeded`. fakeFetch clamps the index to the
282
+ // last response, so all polls past the first see "running".
283
+ const { fetch: f } = fakeFetch([
284
+ { status: 202, body: { operation_id: "op-x" } },
285
+ { status: 200, body: { id: "op-x", status: "running" } },
286
+ ]);
287
+ // Clock seam: each call jumps 1s. With a 50ms timeout the deadline is
288
+ // (first-now + 50), and the second `now()` (the in-loop deadline check)
289
+ // is already 1s past it — so the loop bails before sleeping again.
290
+ let t = 0;
291
+ const now = () => {
292
+ const d = new Date(t);
293
+ t += 1_000;
294
+ return d;
295
+ };
296
+ let err: unknown;
297
+ try {
298
+ await driveModuleOp("vault", "install", {
299
+ db: h.db,
300
+ issuer: ISSUER,
301
+ configDir: h.dir,
302
+ fetch: f,
303
+ sleep: async () => {},
304
+ now,
305
+ pollTimeoutMs: 50,
306
+ pollIntervalMs: 10,
307
+ });
308
+ } catch (e) {
309
+ err = e;
310
+ }
311
+ expect(err).toBeInstanceOf(ModuleOpFailedError);
312
+ expect((err as Error).message).toContain("did not complete within");
313
+ expect((err as Error).message).toContain("op-x");
314
+ });
315
+
316
+ test("passes an optional JSON body through on the POST", async () => {
317
+ h = await makeHarnessWithToken();
318
+ const { fetch: f, calls } = fakeFetch([
319
+ { status: 202, body: { operation_id: "op-1" } },
320
+ { status: 200, body: { id: "op-1", status: "succeeded" } },
321
+ ]);
322
+ await driveModuleOp("vault", "install", {
323
+ db: h.db,
324
+ issuer: ISSUER,
325
+ configDir: h.dir,
326
+ fetch: f,
327
+ sleep: async () => {},
328
+ body: { channel: "rc" },
329
+ });
330
+ expect(calls[0]?.headers["content-type"]).toBe("application/json");
331
+ expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ channel: "rc" });
332
+ });
333
+ });
334
+
335
+ describe("fetchModuleLogs", () => {
336
+ let h: Harness;
337
+ afterEach(() => h.cleanup());
338
+
339
+ test("GETs the /logs endpoint with the operator bearer and returns lines + text", async () => {
340
+ h = await makeHarnessWithToken();
341
+ const { fetch: f, calls } = fakeFetch([
342
+ {
343
+ status: 200,
344
+ body: {
345
+ short: "vault",
346
+ lines: ["[vault] booting\n", "[vault] ready\n"],
347
+ text: "[vault] booting\n[vault] ready\n",
348
+ },
349
+ },
350
+ ]);
351
+ const result = await fetchModuleLogs("vault", {
352
+ db: h.db,
353
+ issuer: ISSUER,
354
+ configDir: h.dir,
355
+ fetch: f,
356
+ });
357
+
358
+ // Hit the right URL with a GET + Bearer.
359
+ expect(calls).toHaveLength(1);
360
+ expect(calls[0]?.url).toBe(`${DEFAULT_HUB_BASE_URL}/api/modules/vault/logs`);
361
+ expect(calls[0]?.method).toBe("GET");
362
+ expect(calls[0]?.headers.authorization).toMatch(/^Bearer /);
363
+
364
+ expect(result.short).toBe("vault");
365
+ expect(result.lines).toEqual(["[vault] booting\n", "[vault] ready\n"]);
366
+ expect(result.text).toBe("[vault] booting\n[vault] ready\n");
367
+ });
368
+
369
+ test("no operator token → NoOperatorTokenError before any fetch", async () => {
370
+ h = await makeHarnessNoToken();
371
+ const { fetch: f, calls } = fakeFetch([{ status: 200, body: { lines: [] } }]);
372
+ let err: unknown;
373
+ try {
374
+ await fetchModuleLogs("vault", { db: h.db, issuer: ISSUER, configDir: h.dir, fetch: f });
375
+ } catch (e) {
376
+ err = e;
377
+ }
378
+ expect(err).toBeInstanceOf(NoOperatorTokenError);
379
+ expect(calls).toHaveLength(0);
380
+ });
381
+
382
+ test("non-2xx (not_supervised) → ModuleOpHttpError carrying status + code", async () => {
383
+ h = await makeHarnessWithToken();
384
+ const { fetch: f } = fakeFetch([
385
+ {
386
+ status: 404,
387
+ body: { error: "not_supervised", error_description: "vault is not currently supervised" },
388
+ },
389
+ ]);
390
+ let err: unknown;
391
+ try {
392
+ await fetchModuleLogs("vault", { db: h.db, issuer: ISSUER, configDir: h.dir, fetch: f });
393
+ } catch (e) {
394
+ err = e;
395
+ }
396
+ expect(err).toBeInstanceOf(ModuleOpHttpError);
397
+ expect((err as ModuleOpHttpError).status).toBe(404);
398
+ expect((err as ModuleOpHttpError).code).toBe("not_supervised");
399
+ });
400
+
401
+ test("falls back to joining lines when the body omits text", async () => {
402
+ h = await makeHarnessWithToken();
403
+ const { fetch: f } = fakeFetch([
404
+ { status: 200, body: { short: "scribe", lines: ["a\n", "b\n"] } },
405
+ ]);
406
+ const result = await fetchModuleLogs("scribe", {
407
+ db: h.db,
408
+ issuer: ISSUER,
409
+ configDir: h.dir,
410
+ fetch: f,
411
+ });
412
+ expect(result.text).toBe("a\nb\n");
413
+ });
414
+ });
415
+
416
+ describe("fetchModuleStates", () => {
417
+ let h: Harness;
418
+ afterEach(() => h.cleanup());
419
+
420
+ test("GETs /api/modules with the operator bearer and parses the supervisor fields", async () => {
421
+ h = await makeHarnessWithToken();
422
+ const { fetch: f, calls } = fakeFetch([
423
+ {
424
+ status: 200,
425
+ body: {
426
+ supervisor_available: true,
427
+ modules: [
428
+ {
429
+ short: "vault",
430
+ installed: true,
431
+ installed_version: "0.6.2",
432
+ supervisor_status: "running",
433
+ pid: 4242,
434
+ supervisor_start_error: null,
435
+ },
436
+ {
437
+ short: "scribe",
438
+ installed: false,
439
+ installed_version: null,
440
+ supervisor_status: null,
441
+ pid: null,
442
+ supervisor_start_error: {
443
+ error_type: "missing_dependency",
444
+ binary: "scribe",
445
+ },
446
+ },
447
+ ],
448
+ },
449
+ },
450
+ ]);
451
+ const result = await fetchModuleStates({
452
+ db: h.db,
453
+ issuer: ISSUER,
454
+ configDir: h.dir,
455
+ fetch: f,
456
+ });
457
+
458
+ // Hits GET /api/modules with a Bearer.
459
+ expect(calls).toHaveLength(1);
460
+ expect(calls[0]?.url).toBe(`${DEFAULT_HUB_BASE_URL}/api/modules`);
461
+ expect(calls[0]?.method).toBe("GET");
462
+ expect(calls[0]?.headers.authorization).toMatch(/^Bearer /);
463
+
464
+ expect(result.supervisorAvailable).toBe(true);
465
+ expect(result.modules).toHaveLength(2);
466
+ const vault = result.modules.find((m) => m.short === "vault");
467
+ expect(vault?.supervisor_status).toBe("running");
468
+ expect(vault?.pid).toBe(4242);
469
+ const scribe = result.modules.find((m) => m.short === "scribe");
470
+ expect(scribe?.supervisor_status).toBeNull();
471
+ expect((scribe?.supervisor_start_error as { binary?: string } | null)?.binary).toBe("scribe");
472
+ });
473
+
474
+ test("no operator token → NoOperatorTokenError before any fetch", async () => {
475
+ h = await makeHarnessNoToken();
476
+ const { fetch: f, calls } = fakeFetch([{ status: 200, body: { modules: [] } }]);
477
+ let err: unknown;
478
+ try {
479
+ await fetchModuleStates({ db: h.db, issuer: ISSUER, configDir: h.dir, fetch: f });
480
+ } catch (e) {
481
+ err = e;
482
+ }
483
+ expect(err).toBeInstanceOf(NoOperatorTokenError);
484
+ expect(calls).toHaveLength(0);
485
+ });
486
+
487
+ test("non-2xx → ModuleOpHttpError (so the status caller can degrade)", async () => {
488
+ h = await makeHarnessWithToken();
489
+ const { fetch: f } = fakeFetch([
490
+ { status: 403, body: { error: "insufficient_scope", error_description: "lacks scope" } },
491
+ ]);
492
+ let err: unknown;
493
+ try {
494
+ await fetchModuleStates({ db: h.db, issuer: ISSUER, configDir: h.dir, fetch: f });
495
+ } catch (e) {
496
+ err = e;
497
+ }
498
+ expect(err).toBeInstanceOf(ModuleOpHttpError);
499
+ expect((err as ModuleOpHttpError).status).toBe(403);
500
+ });
501
+
502
+ test("malformed body (no modules array) → empty modules, not a throw", async () => {
503
+ h = await makeHarnessWithToken();
504
+ const { fetch: f } = fakeFetch([{ status: 200, body: { supervisor_available: false } }]);
505
+ const result = await fetchModuleStates({
506
+ db: h.db,
507
+ issuer: ISSUER,
508
+ configDir: h.dir,
509
+ fetch: f,
510
+ });
511
+ expect(result.supervisorAvailable).toBe(false);
512
+ expect(result.modules).toEqual([]);
513
+ });
514
+
515
+ test("wedged hub (fetch never resolves) → bounded timeout degrades, no hang", async () => {
516
+ h = await makeHarnessWithToken();
517
+ // A hub that accepts the connection but never answers: the fetch settles ONLY
518
+ // when its AbortSignal fires. With a short injected ceiling, fetchModuleStates
519
+ // must reject within the bound (degrade) rather than hang forever.
520
+ let signalRef: AbortSignal | undefined;
521
+ const neverResolving = ((_input: string | URL | Request, init?: RequestInit) => {
522
+ signalRef = init?.signal ?? undefined;
523
+ return new Promise<Response>((_resolve, reject) => {
524
+ // Honor the abort signal the bounded fetch wires in — that's what frees us.
525
+ init?.signal?.addEventListener("abort", () => {
526
+ reject(new DOMException("The operation was aborted.", "AbortError"));
527
+ });
528
+ });
529
+ }) as unknown as typeof fetch;
530
+
531
+ const start = Date.now();
532
+ let err: unknown;
533
+ try {
534
+ await fetchModuleStates({
535
+ db: h.db,
536
+ issuer: ISSUER,
537
+ configDir: h.dir,
538
+ fetch: neverResolving,
539
+ statesFetchTimeoutMs: 25, // short injected ceiling — no real wall-clock wait
540
+ });
541
+ } catch (e) {
542
+ err = e;
543
+ }
544
+ const elapsed = Date.now() - start;
545
+
546
+ // The bounded fetch passed an AbortSignal through to our stub.
547
+ expect(signalRef).toBeInstanceOf(AbortSignal);
548
+ // Resolved within the bound (generous slack for runner jitter), did not hang.
549
+ expect(elapsed).toBeLessThan(2_000);
550
+ // Degrades through the SAME ModuleOpHttpError path the status caller already
551
+ // catches → "couldn't read live module state (…)" note + exit 0, never a hang.
552
+ expect(err).toBeInstanceOf(ModuleOpHttpError);
553
+ expect((err as ModuleOpHttpError).code).toBe("request_timeout");
554
+ expect((err as Error).message).toContain("timed out");
555
+ });
556
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultPortListening } from "../port-probe.ts";
3
+
4
+ describe("defaultPortListening", () => {
5
+ test("true when something is listening on the loopback port", async () => {
6
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") });
7
+ try {
8
+ expect(await defaultPortListening(server.port as number)).toBe(true);
9
+ } finally {
10
+ server.stop(true);
11
+ }
12
+ });
13
+
14
+ test("false when nothing is bound (connection refused)", async () => {
15
+ // Grab a port, immediately release it, then probe — it's free.
16
+ const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
17
+ const freePort = probe.port as number;
18
+ probe.stop(true);
19
+ // Brief settle so the kernel releases the port before we probe.
20
+ await new Promise((r) => setTimeout(r, 50));
21
+ expect(await defaultPortListening(freePort)).toBe(false);
22
+ });
23
+ });