@openparachute/hub 0.5.10-rc.2 → 0.5.10-rc.5
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 +1 -1
- package/src/__tests__/api-modules-ops.test.ts +519 -0
- package/src/__tests__/api-modules.test.ts +292 -0
- package/src/__tests__/hub-server.test.ts +32 -7
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/setup-gate.test.ts +18 -8
- package/src/__tests__/setup-wizard.test.ts +815 -0
- package/src/api-modules-ops.ts +561 -0
- package/src/api-modules.ts +266 -0
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +61 -4
- package/src/help.ts +2 -1
- package/src/hub-server.ts +154 -75
- package/src/setup-wizard.ts +1080 -0
- package/web/ui/dist/assets/index-AX_UHJ5e.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +0 -60
package/package.json
CHANGED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
API_MODULES_OPS_REQUIRED_SCOPE,
|
|
7
|
+
_resetOperationsRegistryForTests,
|
|
8
|
+
handleInstall,
|
|
9
|
+
handleOperationGet,
|
|
10
|
+
handleRestart,
|
|
11
|
+
handleUninstall,
|
|
12
|
+
handleUpgrade,
|
|
13
|
+
parseModulesPath,
|
|
14
|
+
} from "../api-modules-ops.ts";
|
|
15
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
|
+
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
17
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
18
|
+
import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
|
|
19
|
+
import { createUser } from "../users.ts";
|
|
20
|
+
|
|
21
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
22
|
+
|
|
23
|
+
interface Harness {
|
|
24
|
+
dir: string;
|
|
25
|
+
manifestPath: string;
|
|
26
|
+
db: ReturnType<typeof openHubDb>;
|
|
27
|
+
userId: string;
|
|
28
|
+
cleanup: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function makeHarness(): Promise<Harness> {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-modules-ops-"));
|
|
33
|
+
const db = openHubDb(hubDbPath(dir));
|
|
34
|
+
rotateSigningKey(db);
|
|
35
|
+
const user = await createUser(db, "owner", "pw");
|
|
36
|
+
return {
|
|
37
|
+
dir,
|
|
38
|
+
manifestPath: join(dir, "services.json"),
|
|
39
|
+
db,
|
|
40
|
+
userId: user.id,
|
|
41
|
+
cleanup: () => {
|
|
42
|
+
db.close();
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
49
|
+
const signed = await signAccessToken(h.db, {
|
|
50
|
+
sub: h.userId,
|
|
51
|
+
scopes,
|
|
52
|
+
audience: "parachute-hub",
|
|
53
|
+
clientId: "parachute-hub",
|
|
54
|
+
issuer: ISSUER,
|
|
55
|
+
ttlSeconds: 3600,
|
|
56
|
+
});
|
|
57
|
+
recordTokenMint(h.db, {
|
|
58
|
+
jti: signed.jti,
|
|
59
|
+
createdVia: "operator_mint",
|
|
60
|
+
subject: h.userId,
|
|
61
|
+
clientId: "parachute-hub",
|
|
62
|
+
scopes,
|
|
63
|
+
expiresAt: signed.expiresAt,
|
|
64
|
+
});
|
|
65
|
+
return signed.token;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function postReq(path: string, headers: Record<string, string>): Request {
|
|
69
|
+
return new Request(`http://localhost${path}`, { method: "POST", headers });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getReq(path: string, headers: Record<string, string>): Request {
|
|
73
|
+
return new Request(`http://localhost${path}`, { method: "GET", headers });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeIdleSupervisor(): {
|
|
77
|
+
supervisor: Supervisor;
|
|
78
|
+
spawns: SpawnRequest[];
|
|
79
|
+
} {
|
|
80
|
+
const spawns: SpawnRequest[] = [];
|
|
81
|
+
const spawnFn = (req: SpawnRequest): SupervisedProc => {
|
|
82
|
+
spawns.push(req);
|
|
83
|
+
// The fake's `exited` resolves when kill() is called, mirroring a
|
|
84
|
+
// well-behaved child that exits on SIGTERM. Without this, the
|
|
85
|
+
// supervisor's `restart()` awaits forever after the stop signal.
|
|
86
|
+
let resolveExit!: (c: number | null) => void;
|
|
87
|
+
const exited = new Promise<number | null>((r) => {
|
|
88
|
+
resolveExit = r;
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
pid: 7777,
|
|
92
|
+
exited,
|
|
93
|
+
stdout: null,
|
|
94
|
+
stderr: null,
|
|
95
|
+
kill: () => resolveExit(0),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
return { supervisor: new Supervisor({ spawnFn }), spawns };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeManifest(path: string, services: unknown[]): void {
|
|
102
|
+
writeFileSync(path, JSON.stringify({ services }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Run a no-op shell — production calls `bun add`/`bun remove`; tests don't. */
|
|
106
|
+
function alwaysOkRun(): {
|
|
107
|
+
run: (cmd: readonly string[]) => Promise<number>;
|
|
108
|
+
calls: string[][];
|
|
109
|
+
} {
|
|
110
|
+
const calls: string[][] = [];
|
|
111
|
+
return {
|
|
112
|
+
calls,
|
|
113
|
+
run: async (cmd) => {
|
|
114
|
+
calls.push([...cmd]);
|
|
115
|
+
return 0;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("parseModulesPath", () => {
|
|
121
|
+
test("recognizes curated short + action", () => {
|
|
122
|
+
expect(parseModulesPath("/api/modules/vault/install")).toEqual({
|
|
123
|
+
short: "vault",
|
|
124
|
+
rest: "install",
|
|
125
|
+
});
|
|
126
|
+
expect(parseModulesPath("/api/modules/scribe/upgrade")).toEqual({
|
|
127
|
+
short: "scribe",
|
|
128
|
+
rest: "upgrade",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("rejects non-curated shorts (no marketplace yet)", () => {
|
|
133
|
+
// Channel exists in FIRST_PARTY_FALLBACKS but is exploration, not
|
|
134
|
+
// in CURATED_MODULES — the v0.6 surface refuses to drive it via
|
|
135
|
+
// /api/modules.
|
|
136
|
+
expect(parseModulesPath("/api/modules/channel/install")).toBeUndefined();
|
|
137
|
+
expect(parseModulesPath("/api/modules/random/install")).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("rejects malformed paths", () => {
|
|
141
|
+
expect(parseModulesPath("/api/modules/")).toBeUndefined();
|
|
142
|
+
expect(parseModulesPath("/api/modules/vault")).toBeUndefined();
|
|
143
|
+
expect(parseModulesPath("/something/else")).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("POST /api/modules/:short/install", () => {
|
|
148
|
+
let h: Harness;
|
|
149
|
+
beforeEach(async () => {
|
|
150
|
+
h = await makeHarness();
|
|
151
|
+
_resetOperationsRegistryForTests();
|
|
152
|
+
});
|
|
153
|
+
afterEach(() => h.cleanup());
|
|
154
|
+
|
|
155
|
+
test("returns 401 on missing bearer", async () => {
|
|
156
|
+
const { supervisor } = makeIdleSupervisor();
|
|
157
|
+
const res = await handleInstall(postReq("/api/modules/vault/install", {}), "vault", {
|
|
158
|
+
db: h.db,
|
|
159
|
+
issuer: ISSUER,
|
|
160
|
+
manifestPath: h.manifestPath,
|
|
161
|
+
configDir: h.dir,
|
|
162
|
+
supervisor,
|
|
163
|
+
run: async () => 0,
|
|
164
|
+
});
|
|
165
|
+
expect(res.status).toBe(401);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("returns 403 on bearer without parachute:host:admin scope", async () => {
|
|
169
|
+
const { supervisor } = makeIdleSupervisor();
|
|
170
|
+
const bearer = await mintBearer(h, ["scribe:transcribe"]);
|
|
171
|
+
const res = await handleInstall(
|
|
172
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
173
|
+
"vault",
|
|
174
|
+
{
|
|
175
|
+
db: h.db,
|
|
176
|
+
issuer: ISSUER,
|
|
177
|
+
manifestPath: h.manifestPath,
|
|
178
|
+
configDir: h.dir,
|
|
179
|
+
supervisor,
|
|
180
|
+
run: async () => 0,
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
expect(res.status).toBe(403);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("returns 403 on bearer with only :host:auth (not :host:admin) — destructive ops elevated", async () => {
|
|
187
|
+
// `:host:auth` is the read-only catalog scope (`GET /api/modules`).
|
|
188
|
+
// Destructive POSTs are admin-only. Mint a token that carries
|
|
189
|
+
// *only* `:auth` and confirm install is refused — the boundary
|
|
190
|
+
// that keeps automation callers from uninstalling vault.
|
|
191
|
+
const { supervisor } = makeIdleSupervisor();
|
|
192
|
+
const bearer = await mintBearer(h, ["parachute:host:auth"]);
|
|
193
|
+
const res = await handleInstall(
|
|
194
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
195
|
+
"vault",
|
|
196
|
+
{
|
|
197
|
+
db: h.db,
|
|
198
|
+
issuer: ISSUER,
|
|
199
|
+
manifestPath: h.manifestPath,
|
|
200
|
+
configDir: h.dir,
|
|
201
|
+
supervisor,
|
|
202
|
+
run: async () => 0,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
expect(res.status).toBe(403);
|
|
206
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
207
|
+
expect(body.error).toBe("insufficient_scope");
|
|
208
|
+
expect(body.error_description).toContain("parachute:host:admin");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("202 + operation_id, runs bun add + seeds services.json + spawns", async () => {
|
|
212
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
213
|
+
const { run, calls } = alwaysOkRun();
|
|
214
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
215
|
+
const res = await handleInstall(
|
|
216
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
217
|
+
"vault",
|
|
218
|
+
{
|
|
219
|
+
db: h.db,
|
|
220
|
+
issuer: ISSUER,
|
|
221
|
+
manifestPath: h.manifestPath,
|
|
222
|
+
configDir: h.dir,
|
|
223
|
+
supervisor,
|
|
224
|
+
run,
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
expect(res.status).toBe(202);
|
|
228
|
+
const body = (await res.json()) as { operation_id: string };
|
|
229
|
+
expect(body.operation_id).toBeDefined();
|
|
230
|
+
|
|
231
|
+
// Wait a microtask for the async install to settle. The
|
|
232
|
+
// alwaysOkRun returns immediately, so the chain
|
|
233
|
+
// bun-add → seed → spawn happens within one microtask
|
|
234
|
+
// batch — give it two ticks to be safe.
|
|
235
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
236
|
+
|
|
237
|
+
// `bun add` was called with the @latest spec.
|
|
238
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
239
|
+
// services.json now has the vault row (the seed-on-missing path).
|
|
240
|
+
const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
241
|
+
services: Array<{ name: string }>;
|
|
242
|
+
};
|
|
243
|
+
expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
|
|
244
|
+
// Supervisor was handed the spawn.
|
|
245
|
+
expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("idempotent: already-installed + running returns succeeded immediately", async () => {
|
|
249
|
+
// Pre-seed services.json + supervisor state.
|
|
250
|
+
writeManifest(h.manifestPath, [
|
|
251
|
+
{
|
|
252
|
+
name: "parachute-vault",
|
|
253
|
+
port: 1940,
|
|
254
|
+
paths: ["/vault/default"],
|
|
255
|
+
health: "/vault/default/health",
|
|
256
|
+
version: "0.4.5",
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
const { supervisor } = makeIdleSupervisor();
|
|
260
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
261
|
+
|
|
262
|
+
const { run, calls } = alwaysOkRun();
|
|
263
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
264
|
+
const res = await handleInstall(
|
|
265
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
266
|
+
"vault",
|
|
267
|
+
{
|
|
268
|
+
db: h.db,
|
|
269
|
+
issuer: ISSUER,
|
|
270
|
+
manifestPath: h.manifestPath,
|
|
271
|
+
configDir: h.dir,
|
|
272
|
+
supervisor,
|
|
273
|
+
run,
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
expect(res.status).toBe(202);
|
|
277
|
+
const body = (await res.json()) as { operation_id: string };
|
|
278
|
+
|
|
279
|
+
// bun add was NOT called — short-circuit hit.
|
|
280
|
+
expect(calls).toEqual([]);
|
|
281
|
+
|
|
282
|
+
// The operation record is already in succeeded state.
|
|
283
|
+
const opRes = await handleOperationGet(
|
|
284
|
+
getReq(`/api/modules/operations/${body.operation_id}`, {
|
|
285
|
+
authorization: `Bearer ${bearer}`,
|
|
286
|
+
}),
|
|
287
|
+
body.operation_id,
|
|
288
|
+
{
|
|
289
|
+
db: h.db,
|
|
290
|
+
issuer: ISSUER,
|
|
291
|
+
manifestPath: h.manifestPath,
|
|
292
|
+
configDir: h.dir,
|
|
293
|
+
supervisor,
|
|
294
|
+
run,
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
const op = (await opRes.json()) as { status: string };
|
|
298
|
+
expect(op.status).toBe("succeeded");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("failed bun-add surfaces failed status on the operation", async () => {
|
|
302
|
+
const { supervisor } = makeIdleSupervisor();
|
|
303
|
+
// Run returns 1 + findGlobalInstall returns null = real failure.
|
|
304
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
305
|
+
const deps = {
|
|
306
|
+
db: h.db,
|
|
307
|
+
issuer: ISSUER,
|
|
308
|
+
manifestPath: h.manifestPath,
|
|
309
|
+
configDir: h.dir,
|
|
310
|
+
supervisor,
|
|
311
|
+
run: async () => 1,
|
|
312
|
+
findGlobalInstall: () => null,
|
|
313
|
+
};
|
|
314
|
+
const res = await handleInstall(
|
|
315
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
316
|
+
"vault",
|
|
317
|
+
deps,
|
|
318
|
+
);
|
|
319
|
+
const body = (await res.json()) as { operation_id: string };
|
|
320
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
321
|
+
const opRes = await handleOperationGet(
|
|
322
|
+
getReq(`/api/modules/operations/${body.operation_id}`, {
|
|
323
|
+
authorization: `Bearer ${bearer}`,
|
|
324
|
+
}),
|
|
325
|
+
body.operation_id,
|
|
326
|
+
deps,
|
|
327
|
+
);
|
|
328
|
+
const op = (await opRes.json()) as { status: string; error?: string };
|
|
329
|
+
expect(op.status).toBe("failed");
|
|
330
|
+
expect(op.error).toMatch(/bun add -g exited 1/);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("POST /api/modules/:short/restart", () => {
|
|
335
|
+
let h: Harness;
|
|
336
|
+
beforeEach(async () => {
|
|
337
|
+
h = await makeHarness();
|
|
338
|
+
_resetOperationsRegistryForTests();
|
|
339
|
+
});
|
|
340
|
+
afterEach(() => h.cleanup());
|
|
341
|
+
|
|
342
|
+
test("404 not_supervised when module isn't running", async () => {
|
|
343
|
+
const { supervisor } = makeIdleSupervisor();
|
|
344
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
345
|
+
const res = await handleRestart(
|
|
346
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
347
|
+
"vault",
|
|
348
|
+
{
|
|
349
|
+
db: h.db,
|
|
350
|
+
issuer: ISSUER,
|
|
351
|
+
manifestPath: h.manifestPath,
|
|
352
|
+
configDir: h.dir,
|
|
353
|
+
supervisor,
|
|
354
|
+
run: async () => 0,
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
expect(res.status).toBe(404);
|
|
358
|
+
const body = (await res.json()) as { error: string };
|
|
359
|
+
expect(body.error).toBe("not_supervised");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("returns new state on success", async () => {
|
|
363
|
+
const { supervisor } = makeIdleSupervisor();
|
|
364
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
365
|
+
|
|
366
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
367
|
+
const res = await handleRestart(
|
|
368
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
369
|
+
"vault",
|
|
370
|
+
{
|
|
371
|
+
db: h.db,
|
|
372
|
+
issuer: ISSUER,
|
|
373
|
+
manifestPath: h.manifestPath,
|
|
374
|
+
configDir: h.dir,
|
|
375
|
+
supervisor,
|
|
376
|
+
run: async () => 0,
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
expect(res.status).toBe(200);
|
|
380
|
+
const body = (await res.json()) as { short: string; state: { status: string } };
|
|
381
|
+
expect(body.short).toBe("vault");
|
|
382
|
+
// restart sets the state to either restarting or running depending
|
|
383
|
+
// on timing — either is acceptable here as long as it's not crashed/stopped.
|
|
384
|
+
expect(["restarting", "running", "starting"]).toContain(body.state.status);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("POST /api/modules/:short/upgrade", () => {
|
|
389
|
+
let h: Harness;
|
|
390
|
+
beforeEach(async () => {
|
|
391
|
+
h = await makeHarness();
|
|
392
|
+
_resetOperationsRegistryForTests();
|
|
393
|
+
});
|
|
394
|
+
afterEach(() => h.cleanup());
|
|
395
|
+
|
|
396
|
+
test("202 + bun add @latest + restart on already-running module", async () => {
|
|
397
|
+
const { supervisor } = makeIdleSupervisor();
|
|
398
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
399
|
+
|
|
400
|
+
const { run, calls } = alwaysOkRun();
|
|
401
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
402
|
+
const res = await handleUpgrade(
|
|
403
|
+
postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
|
|
404
|
+
"vault",
|
|
405
|
+
{
|
|
406
|
+
db: h.db,
|
|
407
|
+
issuer: ISSUER,
|
|
408
|
+
manifestPath: h.manifestPath,
|
|
409
|
+
configDir: h.dir,
|
|
410
|
+
supervisor,
|
|
411
|
+
run,
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
expect(res.status).toBe(202);
|
|
415
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
416
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("POST /api/modules/:short/uninstall", () => {
|
|
421
|
+
let h: Harness;
|
|
422
|
+
beforeEach(async () => {
|
|
423
|
+
h = await makeHarness();
|
|
424
|
+
_resetOperationsRegistryForTests();
|
|
425
|
+
});
|
|
426
|
+
afterEach(() => h.cleanup());
|
|
427
|
+
|
|
428
|
+
test("stops child + removes services.json row + runs bun remove", async () => {
|
|
429
|
+
writeManifest(h.manifestPath, [
|
|
430
|
+
{
|
|
431
|
+
name: "parachute-vault",
|
|
432
|
+
port: 1940,
|
|
433
|
+
paths: ["/vault/default"],
|
|
434
|
+
health: "/vault/default/health",
|
|
435
|
+
version: "0.4.5",
|
|
436
|
+
},
|
|
437
|
+
]);
|
|
438
|
+
const { supervisor } = makeIdleSupervisor();
|
|
439
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
440
|
+
|
|
441
|
+
const { run, calls } = alwaysOkRun();
|
|
442
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
443
|
+
const res = await handleUninstall(
|
|
444
|
+
postReq("/api/modules/vault/uninstall", { authorization: `Bearer ${bearer}` }),
|
|
445
|
+
"vault",
|
|
446
|
+
{
|
|
447
|
+
db: h.db,
|
|
448
|
+
issuer: ISSUER,
|
|
449
|
+
manifestPath: h.manifestPath,
|
|
450
|
+
configDir: h.dir,
|
|
451
|
+
supervisor,
|
|
452
|
+
run,
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
expect(res.status).toBe(200);
|
|
456
|
+
const body = (await res.json()) as { short: string; log: string[] };
|
|
457
|
+
expect(body.short).toBe("vault");
|
|
458
|
+
// The log captures each step's outcome.
|
|
459
|
+
expect(body.log.join(" ")).toMatch(/supervisor stopped/);
|
|
460
|
+
expect(body.log.join(" ")).toMatch(/removed parachute-vault from services.json/);
|
|
461
|
+
|
|
462
|
+
// services.json row is gone.
|
|
463
|
+
const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
464
|
+
services: Array<{ name: string }>;
|
|
465
|
+
};
|
|
466
|
+
expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(false);
|
|
467
|
+
// bun remove was called.
|
|
468
|
+
expect(calls).toContainEqual(["bun", "remove", "-g", "@openparachute/vault"]);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("idempotent on never-installed module", async () => {
|
|
472
|
+
const { supervisor } = makeIdleSupervisor();
|
|
473
|
+
const { run } = alwaysOkRun();
|
|
474
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
475
|
+
const res = await handleUninstall(
|
|
476
|
+
postReq("/api/modules/vault/uninstall", { authorization: `Bearer ${bearer}` }),
|
|
477
|
+
"vault",
|
|
478
|
+
{
|
|
479
|
+
db: h.db,
|
|
480
|
+
issuer: ISSUER,
|
|
481
|
+
manifestPath: h.manifestPath,
|
|
482
|
+
configDir: h.dir,
|
|
483
|
+
supervisor,
|
|
484
|
+
run,
|
|
485
|
+
},
|
|
486
|
+
);
|
|
487
|
+
expect(res.status).toBe(200);
|
|
488
|
+
const body = (await res.json()) as { log: string[] };
|
|
489
|
+
expect(body.log.join(" ")).toMatch(/not supervised/);
|
|
490
|
+
expect(body.log.join(" ")).toMatch(/not in services.json/);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("GET /api/modules/operations/:id", () => {
|
|
495
|
+
let h: Harness;
|
|
496
|
+
beforeEach(async () => {
|
|
497
|
+
h = await makeHarness();
|
|
498
|
+
_resetOperationsRegistryForTests();
|
|
499
|
+
});
|
|
500
|
+
afterEach(() => h.cleanup());
|
|
501
|
+
|
|
502
|
+
test("404 on unknown id", async () => {
|
|
503
|
+
const { supervisor } = makeIdleSupervisor();
|
|
504
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
505
|
+
const res = await handleOperationGet(
|
|
506
|
+
getReq("/api/modules/operations/no-such-id", { authorization: `Bearer ${bearer}` }),
|
|
507
|
+
"no-such-id",
|
|
508
|
+
{
|
|
509
|
+
db: h.db,
|
|
510
|
+
issuer: ISSUER,
|
|
511
|
+
manifestPath: h.manifestPath,
|
|
512
|
+
configDir: h.dir,
|
|
513
|
+
supervisor,
|
|
514
|
+
run: async () => 0,
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
expect(res.status).toBe(404);
|
|
518
|
+
});
|
|
519
|
+
});
|