@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -11
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +52 -25
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/api-modules.ts +105 -0
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +139 -24
- package/src/hub-settings.ts +163 -1
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +103 -6
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type CheckResult, type DoctorDeps, doctor } from "../commands/doctor.ts";
|
|
6
|
+
import { writePid } from "../process-state.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Doctor tests. The headline is the fresh-install-green guard (#717): a
|
|
10
|
+
* sandboxed PARACHUTE_HOME with a minimal-but-current services.json + a valid
|
|
11
|
+
* operator.token → ALL GREEN, zero WARN/FAIL. Every other test drives ONE
|
|
12
|
+
* failure mode and asserts that check fails while the others stay green.
|
|
13
|
+
*
|
|
14
|
+
* Every external side effect is stubbed through the `deps` seam — no real
|
|
15
|
+
* network probe, no real launchd/systemd query, no touching `~/.parachute`. The
|
|
16
|
+
* only real fs is the sandboxed PARACHUTE_HOME (services.json / operator.token /
|
|
17
|
+
* pidfiles) so the on-disk readers exercise genuine state.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface Harness {
|
|
21
|
+
configDir: string;
|
|
22
|
+
manifestPath: string;
|
|
23
|
+
cleanup: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeHarness(): Harness {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "parachute-doctor-test-"));
|
|
28
|
+
return {
|
|
29
|
+
configDir: dir,
|
|
30
|
+
manifestPath: join(dir, "services.json"),
|
|
31
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A minimal-but-current services.json with the canonical vault row. */
|
|
36
|
+
function seedCurrentManifest(manifestPath: string): void {
|
|
37
|
+
const services = [
|
|
38
|
+
{
|
|
39
|
+
name: "parachute-vault",
|
|
40
|
+
port: 1940,
|
|
41
|
+
paths: ["/vault/default"],
|
|
42
|
+
health: "/health",
|
|
43
|
+
version: "0.7.4",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
writeFileSync(manifestPath, JSON.stringify({ services }, null, 2));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A hand-rolled (unsigned) JWT — doctor DECODES `iss`, never verifies it. */
|
|
50
|
+
function fakeOperatorToken(iss: string): string {
|
|
51
|
+
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
52
|
+
return [
|
|
53
|
+
b64({ alg: "none", typ: "JWT" }),
|
|
54
|
+
b64({ iss, aud: "operator", sub: "u1", pa_scope_set: "admin" }),
|
|
55
|
+
"sig",
|
|
56
|
+
].join(".");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function seedOperatorToken(configDir: string, iss = "http://127.0.0.1:1939"): void {
|
|
60
|
+
writeFileSync(join(configDir, "operator.token"), `${fakeOperatorToken(iss)}\n`, { mode: 0o600 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deps for a HEALTHY box: hub answers /health, the vault module answers /health,
|
|
65
|
+
* the manager reports active, every bin resolves on PATH, nothing exposed.
|
|
66
|
+
* Individual tests override one field to drive a specific failure.
|
|
67
|
+
*/
|
|
68
|
+
function healthyDeps(over: Partial<DoctorDeps> = {}): DoctorDeps {
|
|
69
|
+
return {
|
|
70
|
+
probeHubHealth: async () => true,
|
|
71
|
+
probeModuleHealth: async () => true,
|
|
72
|
+
probePublicHealth: async () => true,
|
|
73
|
+
queryHubUnitState: () => ({ state: "active" }),
|
|
74
|
+
// A `which` that resolves everything — so the bin exec-bit check passes
|
|
75
|
+
// without the real module binaries being on the test host's PATH.
|
|
76
|
+
which: (binary) => `/usr/local/bin/${binary}`,
|
|
77
|
+
now: () => new Date("2026-06-27T00:00:00Z"),
|
|
78
|
+
...over,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runDoctor(
|
|
83
|
+
h: Harness,
|
|
84
|
+
deps: DoctorDeps,
|
|
85
|
+
): Promise<{ code: number; checks: CheckResult[] }> {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
const code = await doctor({
|
|
88
|
+
configDir: h.configDir,
|
|
89
|
+
manifestPath: h.manifestPath,
|
|
90
|
+
print: (l) => lines.push(l),
|
|
91
|
+
json: true,
|
|
92
|
+
deps,
|
|
93
|
+
});
|
|
94
|
+
const payload = JSON.parse(lines.join("\n")) as { checks: CheckResult[] };
|
|
95
|
+
return { code, checks: payload.checks };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function byName(checks: CheckResult[], name: string): CheckResult | undefined {
|
|
99
|
+
return checks.find((c) => c.name === name);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expectNoUnexpectedNonPass(checks: CheckResult[], allowedFailing: string[]): void {
|
|
103
|
+
const offenders = checks.filter((c) => c.status !== "pass" && !allowedFailing.includes(c.name));
|
|
104
|
+
if (offenders.length > 0) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`unexpected non-pass checks: ${offenders.map((c) => `${c.name}=${c.status}`).join(", ")}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("doctor — the fresh-install-green headline guard (#717)", () => {
|
|
112
|
+
test("a minimal-but-current install with a valid operator.token → ALL GREEN, exit 0", async () => {
|
|
113
|
+
const h = makeHarness();
|
|
114
|
+
try {
|
|
115
|
+
seedCurrentManifest(h.manifestPath);
|
|
116
|
+
seedOperatorToken(h.configDir);
|
|
117
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
118
|
+
|
|
119
|
+
// The load-bearing assertion: not a single WARN or FAIL anywhere.
|
|
120
|
+
const nonPass = checks.filter((c) => c.status !== "pass");
|
|
121
|
+
expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
|
|
122
|
+
expect(checks.every((c) => c.status === "pass")).toBe(true);
|
|
123
|
+
expect(code).toBe(0);
|
|
124
|
+
} finally {
|
|
125
|
+
h.cleanup();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a brand-new box (no services.json, no operator.token) → ALL GREEN, exit 0", async () => {
|
|
130
|
+
const h = makeHarness();
|
|
131
|
+
try {
|
|
132
|
+
// Nothing seeded at all — the truly-fresh case before `parachute init`.
|
|
133
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
134
|
+
const nonPass = checks.filter((c) => c.status !== "pass");
|
|
135
|
+
expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
|
|
136
|
+
expect(code).toBe(0);
|
|
137
|
+
} finally {
|
|
138
|
+
h.cleanup();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("an EXPOSED + reachable box stays GREEN", async () => {
|
|
143
|
+
const h = makeHarness();
|
|
144
|
+
try {
|
|
145
|
+
seedCurrentManifest(h.manifestPath);
|
|
146
|
+
seedOperatorToken(h.configDir, "https://vault.example.com");
|
|
147
|
+
writeFileSync(
|
|
148
|
+
join(h.configDir, "expose-state.json"),
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
version: 1,
|
|
151
|
+
layer: "public",
|
|
152
|
+
mode: "path",
|
|
153
|
+
canonicalFqdn: "vault.example.com",
|
|
154
|
+
port: 1939,
|
|
155
|
+
funnel: true,
|
|
156
|
+
entries: [],
|
|
157
|
+
hubOrigin: "https://vault.example.com",
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
const { code, checks } = await runDoctor(
|
|
161
|
+
h,
|
|
162
|
+
healthyDeps({ probePublicHealth: async () => true }),
|
|
163
|
+
);
|
|
164
|
+
const nonPass = checks.filter((c) => c.status !== "pass");
|
|
165
|
+
expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
|
|
166
|
+
expect(code).toBe(0);
|
|
167
|
+
} finally {
|
|
168
|
+
h.cleanup();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("doctor — failure modes (each detected in isolation; others stay green)", () => {
|
|
174
|
+
test("hub down → hub-reachable FAILs, exit 1, modules check WARNs (not N fails)", async () => {
|
|
175
|
+
const h = makeHarness();
|
|
176
|
+
try {
|
|
177
|
+
seedCurrentManifest(h.manifestPath);
|
|
178
|
+
seedOperatorToken(h.configDir);
|
|
179
|
+
const { code, checks } = await runDoctor(
|
|
180
|
+
h,
|
|
181
|
+
healthyDeps({
|
|
182
|
+
probeHubHealth: async () => false,
|
|
183
|
+
queryHubUnitState: () => ({ state: "inactive" }),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
expect(byName(checks, "hub-reachable")?.status).toBe("fail");
|
|
187
|
+
// A down hub → don't pile N module FAILs; surface one WARN pointing at the hub.
|
|
188
|
+
expect(byName(checks, "modules-alive")?.status).toBe("warn");
|
|
189
|
+
expect(code).toBe(1);
|
|
190
|
+
// Everything else stays green.
|
|
191
|
+
expectNoUnexpectedNonPass(checks, ["hub-reachable", "modules-alive"]);
|
|
192
|
+
} finally {
|
|
193
|
+
h.cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a configured module that doesn't answer /health on a healthy hub → that module FAILs", async () => {
|
|
198
|
+
const h = makeHarness();
|
|
199
|
+
try {
|
|
200
|
+
seedCurrentManifest(h.manifestPath);
|
|
201
|
+
seedOperatorToken(h.configDir);
|
|
202
|
+
const { code, checks } = await runDoctor(
|
|
203
|
+
h,
|
|
204
|
+
healthyDeps({ probeModuleHealth: async () => false }),
|
|
205
|
+
);
|
|
206
|
+
const mod = byName(checks, "module-alive:vault");
|
|
207
|
+
expect(mod?.status).toBe("fail");
|
|
208
|
+
expect(mod?.fix).toContain("parachute restart vault");
|
|
209
|
+
expect(code).toBe(1);
|
|
210
|
+
expectNoUnexpectedNonPass(checks, ["module-alive:vault"]);
|
|
211
|
+
} finally {
|
|
212
|
+
h.cleanup();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("missing operator token → operator-token PASSES (feature-not-configured, NOT a failure)", async () => {
|
|
217
|
+
const h = makeHarness();
|
|
218
|
+
try {
|
|
219
|
+
seedCurrentManifest(h.manifestPath);
|
|
220
|
+
// No operator.token seeded.
|
|
221
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
222
|
+
expect(byName(checks, "operator-token")?.status).toBe("pass");
|
|
223
|
+
expect(code).toBe(0);
|
|
224
|
+
} finally {
|
|
225
|
+
h.cleanup();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("corrupt operator token (not a JWT) → operator-token FAILs, exit 1", async () => {
|
|
230
|
+
const h = makeHarness();
|
|
231
|
+
try {
|
|
232
|
+
seedCurrentManifest(h.manifestPath);
|
|
233
|
+
writeFileSync(join(h.configDir, "operator.token"), "not-a-jwt\n", { mode: 0o600 });
|
|
234
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
235
|
+
expect(byName(checks, "operator-token")?.status).toBe("fail");
|
|
236
|
+
expect(byName(checks, "operator-token")?.detail).toContain("decodable");
|
|
237
|
+
expect(code).toBe(1);
|
|
238
|
+
expectNoUnexpectedNonPass(checks, ["operator-token"]);
|
|
239
|
+
} finally {
|
|
240
|
+
h.cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("issuer mismatch (foreign iss) → operator-token FAILs with the 'not signed in' detail", async () => {
|
|
245
|
+
const h = makeHarness();
|
|
246
|
+
try {
|
|
247
|
+
seedCurrentManifest(h.manifestPath);
|
|
248
|
+
// An `iss` that is neither loopback nor any exposed/env origin.
|
|
249
|
+
seedOperatorToken(h.configDir, "https://stale.example.com");
|
|
250
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
251
|
+
const op = byName(checks, "operator-token");
|
|
252
|
+
expect(op?.status).toBe("fail");
|
|
253
|
+
expect(op?.detail).toContain("not signed in");
|
|
254
|
+
expect(op?.fix).toContain("start hub");
|
|
255
|
+
expect(code).toBe(1);
|
|
256
|
+
expectNoUnexpectedNonPass(checks, ["operator-token"]);
|
|
257
|
+
} finally {
|
|
258
|
+
h.cleanup();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("module bin missing the exec bit (100644) → module-bin FAILs with a chmod +x fix", async () => {
|
|
263
|
+
const h = makeHarness();
|
|
264
|
+
try {
|
|
265
|
+
seedCurrentManifest(h.manifestPath);
|
|
266
|
+
seedOperatorToken(h.configDir);
|
|
267
|
+
// `which` returns null (Bun.which requires X_OK → null on a 100644 bin),
|
|
268
|
+
// and the secondary probe finds the present-but-non-executable file.
|
|
269
|
+
const { code, checks } = await runDoctor(
|
|
270
|
+
h,
|
|
271
|
+
healthyDeps({
|
|
272
|
+
which: () => null,
|
|
273
|
+
findNonExecutable: (binary) => `/usr/local/bin/${binary}`,
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
const bin = byName(checks, "module-bin:vault");
|
|
277
|
+
expect(bin?.status).toBe("fail");
|
|
278
|
+
expect(bin?.detail).toContain("NOT executable");
|
|
279
|
+
expect(bin?.fix).toContain("chmod +x");
|
|
280
|
+
expect(code).toBe(1);
|
|
281
|
+
expectNoUnexpectedNonPass(checks, ["module-bin:vault"]);
|
|
282
|
+
} finally {
|
|
283
|
+
h.cleanup();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("module bin genuinely not on PATH → module-bin FAILs with a reinstall fix", async () => {
|
|
288
|
+
const h = makeHarness();
|
|
289
|
+
try {
|
|
290
|
+
seedCurrentManifest(h.manifestPath);
|
|
291
|
+
seedOperatorToken(h.configDir);
|
|
292
|
+
const { code, checks } = await runDoctor(
|
|
293
|
+
h,
|
|
294
|
+
healthyDeps({ which: () => null, findNonExecutable: () => null }),
|
|
295
|
+
);
|
|
296
|
+
const bin = byName(checks, "module-bin:vault");
|
|
297
|
+
expect(bin?.status).toBe("fail");
|
|
298
|
+
expect(bin?.fix).toContain("parachute install vault");
|
|
299
|
+
expect(code).toBe(1);
|
|
300
|
+
expectNoUnexpectedNonPass(checks, ["module-bin:vault"]);
|
|
301
|
+
} finally {
|
|
302
|
+
h.cleanup();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("malformed services.json → services-manifest FAILs, exit 1", async () => {
|
|
307
|
+
const h = makeHarness();
|
|
308
|
+
try {
|
|
309
|
+
// A row missing the required `port` field → strict readManifest throws.
|
|
310
|
+
writeFileSync(
|
|
311
|
+
h.manifestPath,
|
|
312
|
+
JSON.stringify({
|
|
313
|
+
services: [{ name: "parachute-vault", paths: ["/v"], health: "/health", version: "1" }],
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
seedOperatorToken(h.configDir);
|
|
317
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
318
|
+
expect(byName(checks, "services-manifest")?.status).toBe("fail");
|
|
319
|
+
expect(code).toBe(1);
|
|
320
|
+
} finally {
|
|
321
|
+
h.cleanup();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("legacy detached install (hub pidfile present) → migration WARNs, exit STAYS 0", async () => {
|
|
326
|
+
const h = makeHarness();
|
|
327
|
+
try {
|
|
328
|
+
seedCurrentManifest(h.manifestPath);
|
|
329
|
+
seedOperatorToken(h.configDir);
|
|
330
|
+
// The detached-era fingerprint: a hub pidfile.
|
|
331
|
+
mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
|
|
332
|
+
writePid("hub", 12345, h.configDir);
|
|
333
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
334
|
+
expect(byName(checks, "migration-detached")?.status).toBe("warn");
|
|
335
|
+
expect(byName(checks, "migration-detached")?.fix).toContain("--to-supervised");
|
|
336
|
+
// Title must describe the DETECTED condition, not its absence — a warn
|
|
337
|
+
// titled "No legacy detached install" is the title-vs-status bug.
|
|
338
|
+
const detachedTitle = byName(checks, "migration-detached")?.title ?? "";
|
|
339
|
+
expect(detachedTitle.toLowerCase()).toContain("detached");
|
|
340
|
+
expect(detachedTitle).not.toMatch(/^no /i);
|
|
341
|
+
// A WARN is advisory — exit code stays 0.
|
|
342
|
+
expect(code).toBe(0);
|
|
343
|
+
expectNoUnexpectedNonPass(checks, ["migration-detached"]);
|
|
344
|
+
} finally {
|
|
345
|
+
h.cleanup();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("known cruft at the ecosystem root → migration WARNs, exit STAYS 0", async () => {
|
|
350
|
+
const h = makeHarness();
|
|
351
|
+
try {
|
|
352
|
+
seedCurrentManifest(h.manifestPath);
|
|
353
|
+
seedOperatorToken(h.configDir);
|
|
354
|
+
// `server.yaml` is an explicit KNOWN_CRUFT rule (legacy server config).
|
|
355
|
+
writeFileSync(join(h.configDir, "server.yaml"), "legacy: true\n");
|
|
356
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
357
|
+
expect(byName(checks, "migration-cruft")?.status).toBe("warn");
|
|
358
|
+
expect(byName(checks, "migration-cruft")?.fix).toBe("parachute migrate");
|
|
359
|
+
// Title must describe the DETECTED condition, not its absence.
|
|
360
|
+
const cruftTitle = byName(checks, "migration-cruft")?.title ?? "";
|
|
361
|
+
expect(cruftTitle.toLowerCase()).toContain("cruft");
|
|
362
|
+
expect(cruftTitle).not.toMatch(/^no /i);
|
|
363
|
+
expect(code).toBe(0);
|
|
364
|
+
expectNoUnexpectedNonPass(checks, ["migration-cruft"]);
|
|
365
|
+
} finally {
|
|
366
|
+
h.cleanup();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("an UNKNOWN file at the root does NOT trip migration (allowlist, not blocklist)", async () => {
|
|
371
|
+
const h = makeHarness();
|
|
372
|
+
try {
|
|
373
|
+
seedCurrentManifest(h.manifestPath);
|
|
374
|
+
seedOperatorToken(h.configDir);
|
|
375
|
+
// A file doctor has never heard of — the exact thing #717 forbids flagging.
|
|
376
|
+
writeFileSync(join(h.configDir, "my-own-thing.json"), "{}\n");
|
|
377
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
378
|
+
// Migration stays a single PASS — no false WARN on the unfamiliar file.
|
|
379
|
+
expect(byName(checks, "migration")?.status).toBe("pass");
|
|
380
|
+
expect(code).toBe(0);
|
|
381
|
+
} finally {
|
|
382
|
+
h.cleanup();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("doctor — Tier 2 exposure (guarded hard)", () => {
|
|
388
|
+
test("not exposed → 'loopback only' is benign info (PASS), not a warning", async () => {
|
|
389
|
+
const h = makeHarness();
|
|
390
|
+
try {
|
|
391
|
+
seedCurrentManifest(h.manifestPath);
|
|
392
|
+
seedOperatorToken(h.configDir);
|
|
393
|
+
// No expose-state.json — the loopback-only box.
|
|
394
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
395
|
+
const ex = byName(checks, "exposure");
|
|
396
|
+
expect(ex?.status).toBe("pass");
|
|
397
|
+
expect(ex?.detail).toContain("loopback only");
|
|
398
|
+
expect(code).toBe(0);
|
|
399
|
+
} finally {
|
|
400
|
+
h.cleanup();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("exposed but the public origin doesn't answer → WARN (never FAIL), exit STAYS 0", async () => {
|
|
405
|
+
const h = makeHarness();
|
|
406
|
+
try {
|
|
407
|
+
seedCurrentManifest(h.manifestPath);
|
|
408
|
+
seedOperatorToken(h.configDir, "https://vault.example.com");
|
|
409
|
+
writeFileSync(
|
|
410
|
+
join(h.configDir, "expose-state.json"),
|
|
411
|
+
JSON.stringify({
|
|
412
|
+
version: 1,
|
|
413
|
+
layer: "public",
|
|
414
|
+
mode: "path",
|
|
415
|
+
canonicalFqdn: "vault.example.com",
|
|
416
|
+
port: 1939,
|
|
417
|
+
funnel: true,
|
|
418
|
+
entries: [],
|
|
419
|
+
hubOrigin: "https://vault.example.com",
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
const { code, checks } = await runDoctor(
|
|
423
|
+
h,
|
|
424
|
+
healthyDeps({ probePublicHealth: async () => false }),
|
|
425
|
+
);
|
|
426
|
+
expect(byName(checks, "exposure")?.status).toBe("warn");
|
|
427
|
+
expect(code).toBe(0);
|
|
428
|
+
expectNoUnexpectedNonPass(checks, ["exposure"]);
|
|
429
|
+
} finally {
|
|
430
|
+
h.cleanup();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("doctor — version drift (cosmetic; never FAIL)", () => {
|
|
436
|
+
test("a 0.0.0-linked stopgap version → WARN labeled cosmetic, exit STAYS 0", async () => {
|
|
437
|
+
const h = makeHarness();
|
|
438
|
+
try {
|
|
439
|
+
writeFileSync(
|
|
440
|
+
h.manifestPath,
|
|
441
|
+
JSON.stringify({
|
|
442
|
+
services: [
|
|
443
|
+
{
|
|
444
|
+
name: "parachute-vault",
|
|
445
|
+
port: 1940,
|
|
446
|
+
paths: ["/vault/default"],
|
|
447
|
+
health: "/health",
|
|
448
|
+
version: "0.0.0-linked",
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
seedOperatorToken(h.configDir);
|
|
454
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
455
|
+
const vd = byName(checks, "version-drift");
|
|
456
|
+
expect(vd?.status).toBe("warn");
|
|
457
|
+
expect(vd?.detail).toContain("cosmetic");
|
|
458
|
+
expect(code).toBe(0);
|
|
459
|
+
expectNoUnexpectedNonPass(checks, ["version-drift"]);
|
|
460
|
+
} finally {
|
|
461
|
+
h.cleanup();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// Canonical-port-drift detection + `doctor --fix` repair (#267 doctor sub-item)
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
/** Write a services.json with the given rows (verbatim — for drift fixtures). */
|
|
471
|
+
function writeManifestRows(manifestPath: string, services: unknown[]): void {
|
|
472
|
+
writeFileSync(manifestPath, JSON.stringify({ services }, null, 2));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Read services.json back as parsed rows for post-fix assertions. */
|
|
476
|
+
function readRows(manifestPath: string): Record<string, unknown>[] {
|
|
477
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
|
478
|
+
services: Record<string, unknown>[];
|
|
479
|
+
};
|
|
480
|
+
return parsed.services;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Run `doctor --fix`, capturing printed lines + exit code. */
|
|
484
|
+
async function runFix(
|
|
485
|
+
h: Harness,
|
|
486
|
+
over: Partial<DoctorDeps> = {},
|
|
487
|
+
flags: { yes?: boolean } = {},
|
|
488
|
+
): Promise<{ code: number; lines: string[] }> {
|
|
489
|
+
const lines: string[] = [];
|
|
490
|
+
const code = await doctor({
|
|
491
|
+
configDir: h.configDir,
|
|
492
|
+
manifestPath: h.manifestPath,
|
|
493
|
+
print: (l) => lines.push(l),
|
|
494
|
+
fix: true,
|
|
495
|
+
yes: flags.yes ?? false,
|
|
496
|
+
deps: healthyDeps(over),
|
|
497
|
+
});
|
|
498
|
+
return { code, lines };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
describe("doctor — canonical-port-drift detection (read-only)", () => {
|
|
502
|
+
test("a non-canonical port + a duplicate-port pair → port-drift WARNs naming the services", async () => {
|
|
503
|
+
const h = makeHarness();
|
|
504
|
+
try {
|
|
505
|
+
// scribe drifted off 1943 onto 1944; agent also squats 1944 (a collision).
|
|
506
|
+
writeManifestRows(h.manifestPath, [
|
|
507
|
+
{
|
|
508
|
+
name: "parachute-vault",
|
|
509
|
+
port: 1940,
|
|
510
|
+
paths: ["/vault/default"],
|
|
511
|
+
health: "/h",
|
|
512
|
+
version: "1",
|
|
513
|
+
},
|
|
514
|
+
{ name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
|
|
515
|
+
{ name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
|
|
516
|
+
]);
|
|
517
|
+
seedOperatorToken(h.configDir);
|
|
518
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
519
|
+
const pd = byName(checks, "port-drift");
|
|
520
|
+
expect(pd?.status).toBe("warn");
|
|
521
|
+
// Names the drifted service AND the colliding pair.
|
|
522
|
+
expect(pd?.detail).toContain("scribe");
|
|
523
|
+
expect(pd?.detail).toContain("1944");
|
|
524
|
+
expect(pd?.detail).toContain("parachute-scribe + parachute-agent");
|
|
525
|
+
expect(pd?.fix).toBe("parachute doctor --fix");
|
|
526
|
+
// Drift is advisory — exit stays 0 (a WARN, not a FAIL). The duplicate
|
|
527
|
+
// rows also trip modules-alive (both can't bind 1944) but that's expected
|
|
528
|
+
// for this fixture; we only assert on port-drift here.
|
|
529
|
+
expect([0, 1]).toContain(code);
|
|
530
|
+
} finally {
|
|
531
|
+
h.cleanup();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("a clean file → port-drift PASSES with no drift", async () => {
|
|
536
|
+
const h = makeHarness();
|
|
537
|
+
try {
|
|
538
|
+
seedCurrentManifest(h.manifestPath);
|
|
539
|
+
seedOperatorToken(h.configDir);
|
|
540
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
541
|
+
const pd = byName(checks, "port-drift");
|
|
542
|
+
expect(pd?.status).toBe("pass");
|
|
543
|
+
expect(pd?.detail).toContain("canonical");
|
|
544
|
+
expect(code).toBe(0);
|
|
545
|
+
expectNoUnexpectedNonPass(checks, []);
|
|
546
|
+
} finally {
|
|
547
|
+
h.cleanup();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("a third-party service with NO canonical port is not flagged", async () => {
|
|
552
|
+
const h = makeHarness();
|
|
553
|
+
try {
|
|
554
|
+
// An unknown module on a non-1939–1949 port — no canonical to drift from.
|
|
555
|
+
writeManifestRows(h.manifestPath, [
|
|
556
|
+
{
|
|
557
|
+
name: "parachute-vault",
|
|
558
|
+
port: 1940,
|
|
559
|
+
paths: ["/vault/default"],
|
|
560
|
+
health: "/h",
|
|
561
|
+
version: "1",
|
|
562
|
+
},
|
|
563
|
+
{ name: "acme-thing", port: 5000, paths: ["/acme"], health: "/h", version: "1" },
|
|
564
|
+
]);
|
|
565
|
+
seedOperatorToken(h.configDir);
|
|
566
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
567
|
+
const pd = byName(checks, "port-drift");
|
|
568
|
+
expect(pd?.status).toBe("pass");
|
|
569
|
+
expect(code).toBe(0);
|
|
570
|
+
} finally {
|
|
571
|
+
h.cleanup();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("a named multi-vault row sharing 1940 is NOT flagged (drift or duplicate)", async () => {
|
|
576
|
+
const h = makeHarness();
|
|
577
|
+
try {
|
|
578
|
+
// A legit multi-vault setup: the canonical vault row plus a second named
|
|
579
|
+
// vault instance, both on 1940 (the documented carve-out). Neither should
|
|
580
|
+
// be flagged as drifted (named vault rows have no canonical port) nor as a
|
|
581
|
+
// duplicate-port collision (all-vault-on-1940 is by design).
|
|
582
|
+
writeManifestRows(h.manifestPath, [
|
|
583
|
+
{
|
|
584
|
+
name: "parachute-vault",
|
|
585
|
+
port: 1940,
|
|
586
|
+
paths: ["/vault/default"],
|
|
587
|
+
health: "/h",
|
|
588
|
+
version: "1",
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: "parachute-vault-work",
|
|
592
|
+
port: 1940,
|
|
593
|
+
paths: ["/vault/work"],
|
|
594
|
+
health: "/h",
|
|
595
|
+
version: "1",
|
|
596
|
+
},
|
|
597
|
+
]);
|
|
598
|
+
seedOperatorToken(h.configDir);
|
|
599
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
600
|
+
const pd = byName(checks, "port-drift");
|
|
601
|
+
// PASS (clean) — neither flagged as drifted nor as a duplicate collision.
|
|
602
|
+
expect(pd?.status).toBe("pass");
|
|
603
|
+
expect(pd?.detail).toContain("canonical");
|
|
604
|
+
expect(code).toBe(0);
|
|
605
|
+
} finally {
|
|
606
|
+
h.cleanup();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non-tty-safe)", () => {
|
|
612
|
+
test("--fix on a clean file → 'no drift', exit 0, file unchanged (idempotent)", async () => {
|
|
613
|
+
const h = makeHarness();
|
|
614
|
+
try {
|
|
615
|
+
seedCurrentManifest(h.manifestPath);
|
|
616
|
+
const before = readFileSync(h.manifestPath, "utf8");
|
|
617
|
+
const { code, lines } = await runFix(h, { isInteractive: () => true }, { yes: true });
|
|
618
|
+
expect(code).toBe(0);
|
|
619
|
+
expect(lines.join("\n").toLowerCase()).toContain("nothing to fix");
|
|
620
|
+
expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
|
|
621
|
+
} finally {
|
|
622
|
+
h.cleanup();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("--fix on an absent services.json (fresh install) → 'nothing to fix', exit 0", async () => {
|
|
627
|
+
const h = makeHarness();
|
|
628
|
+
try {
|
|
629
|
+
// No services.json at all — the truly-fresh case. --fix must NOT report a
|
|
630
|
+
// corrupt-file error; it's the idempotent no-op path.
|
|
631
|
+
const { code, lines } = await runFix(h, { isInteractive: () => false }, { yes: false });
|
|
632
|
+
expect(code).toBe(0);
|
|
633
|
+
expect(lines.join("\n").toLowerCase()).toContain("nothing to fix");
|
|
634
|
+
expect(lines.join("\n").toLowerCase()).not.toContain("can't read");
|
|
635
|
+
} finally {
|
|
636
|
+
h.cleanup();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("--fix --yes rewrites the drifted port to canonical + preserves other fields", async () => {
|
|
641
|
+
const h = makeHarness();
|
|
642
|
+
try {
|
|
643
|
+
// scribe drifted onto 1944; carries an optional displayName/tagline that
|
|
644
|
+
// must survive the rewrite.
|
|
645
|
+
writeManifestRows(h.manifestPath, [
|
|
646
|
+
{
|
|
647
|
+
name: "parachute-scribe",
|
|
648
|
+
port: 1944,
|
|
649
|
+
paths: ["/scribe"],
|
|
650
|
+
health: "/scribe/health",
|
|
651
|
+
version: "0.7.4",
|
|
652
|
+
displayName: "Scribe",
|
|
653
|
+
tagline: "Local audio transcription.",
|
|
654
|
+
stripPrefix: true,
|
|
655
|
+
},
|
|
656
|
+
]);
|
|
657
|
+
const { code, lines } = await runFix(h, {}, { yes: true });
|
|
658
|
+
expect(code).toBe(0);
|
|
659
|
+
expect(lines.join("\n")).toContain("→ :1943");
|
|
660
|
+
const rows = readRows(h.manifestPath);
|
|
661
|
+
const scribe = rows.find((r) => r.name === "parachute-scribe");
|
|
662
|
+
expect(scribe?.port).toBe(1943);
|
|
663
|
+
// Optional + unknown fields preserved verbatim.
|
|
664
|
+
expect(scribe?.displayName).toBe("Scribe");
|
|
665
|
+
expect(scribe?.tagline).toBe("Local audio transcription.");
|
|
666
|
+
expect(scribe?.stripPrefix).toBe(true);
|
|
667
|
+
|
|
668
|
+
// Re-run → idempotent: now clean, exit 0, nothing to fix.
|
|
669
|
+
const again = await runFix(h, {}, { yes: true });
|
|
670
|
+
expect(again.code).toBe(0);
|
|
671
|
+
expect(again.lines.join("\n").toLowerCase()).toContain("nothing to fix");
|
|
672
|
+
} finally {
|
|
673
|
+
h.cleanup();
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("--fix in a TTY without --yes prompts; 'y' applies the rewrite", async () => {
|
|
678
|
+
const h = makeHarness();
|
|
679
|
+
try {
|
|
680
|
+
writeManifestRows(h.manifestPath, [
|
|
681
|
+
{ name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
|
|
682
|
+
]);
|
|
683
|
+
const { code } = await runFix(
|
|
684
|
+
h,
|
|
685
|
+
{ isInteractive: () => true, readLine: async () => "y" },
|
|
686
|
+
{ yes: false },
|
|
687
|
+
);
|
|
688
|
+
expect(code).toBe(0);
|
|
689
|
+
expect(readRows(h.manifestPath).find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
|
|
690
|
+
} finally {
|
|
691
|
+
h.cleanup();
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("--fix in a TTY answered 'n' → aborts, exit non-zero, file UNCHANGED", async () => {
|
|
696
|
+
const h = makeHarness();
|
|
697
|
+
try {
|
|
698
|
+
writeManifestRows(h.manifestPath, [
|
|
699
|
+
{ name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
|
|
700
|
+
]);
|
|
701
|
+
const before = readFileSync(h.manifestPath, "utf8");
|
|
702
|
+
const { code, lines } = await runFix(
|
|
703
|
+
h,
|
|
704
|
+
{ isInteractive: () => true, readLine: async () => "n" },
|
|
705
|
+
{ yes: false },
|
|
706
|
+
);
|
|
707
|
+
expect(code).not.toBe(0);
|
|
708
|
+
expect(lines.join("\n").toLowerCase()).toContain("unchanged");
|
|
709
|
+
expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
|
|
710
|
+
} finally {
|
|
711
|
+
h.cleanup();
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("--fix in a NON-TTY without --yes → bails, exit non-zero, file UNCHANGED", async () => {
|
|
716
|
+
const h = makeHarness();
|
|
717
|
+
try {
|
|
718
|
+
writeManifestRows(h.manifestPath, [
|
|
719
|
+
{ name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
|
|
720
|
+
]);
|
|
721
|
+
const before = readFileSync(h.manifestPath, "utf8");
|
|
722
|
+
const { code, lines } = await runFix(h, { isInteractive: () => false }, { yes: false });
|
|
723
|
+
expect(code).not.toBe(0);
|
|
724
|
+
expect(lines.join("\n")).toContain("--yes");
|
|
725
|
+
// The load-bearing guarantee: no write happened.
|
|
726
|
+
expect(readFileSync(h.manifestPath, "utf8")).toBe(before);
|
|
727
|
+
} finally {
|
|
728
|
+
h.cleanup();
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("--fix reports a duplicate-port collision but does not auto-resolve it", async () => {
|
|
733
|
+
const h = makeHarness();
|
|
734
|
+
try {
|
|
735
|
+
// Two services collide on 1944; neither is on its canonical slot. The
|
|
736
|
+
// diff fixes the canonical drift; the collision is reported, not guessed.
|
|
737
|
+
writeManifestRows(h.manifestPath, [
|
|
738
|
+
{ name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
|
|
739
|
+
{ name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
|
|
740
|
+
]);
|
|
741
|
+
const { code, lines } = await runFix(h, {}, { yes: true });
|
|
742
|
+
const text = lines.join("\n");
|
|
743
|
+
expect(text.toLowerCase()).toContain("shared by");
|
|
744
|
+
expect(text).toContain("parachute-scribe + parachute-agent");
|
|
745
|
+
// scribe → 1943 and agent → 1941 are both off 1944, so after the rewrite
|
|
746
|
+
// they no longer collide; fix applied, exit 0.
|
|
747
|
+
expect(code).toBe(0);
|
|
748
|
+
const rows = readRows(h.manifestPath);
|
|
749
|
+
expect(rows.find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
|
|
750
|
+
expect(rows.find((r) => r.name === "parachute-agent")?.port).toBe(1941);
|
|
751
|
+
} finally {
|
|
752
|
+
h.cleanup();
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
});
|