@openparachute/hub 0.3.0-rc.1 → 0.5.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 +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { 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 { HOST_ADMIN_SCOPE, type RunResult, handleCreateVault } from "../admin-vaults.ts";
|
|
6
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
8
|
+
import { upsertService, writeManifest } from "../services-manifest.ts";
|
|
9
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
10
|
+
|
|
11
|
+
/** Build the JSON shape parachute-vault create --json emits (PR #184). */
|
|
12
|
+
function vaultCreateJson(name: string, token = `pvt_${name}_token`): string {
|
|
13
|
+
return JSON.stringify({
|
|
14
|
+
name,
|
|
15
|
+
token,
|
|
16
|
+
paths: {
|
|
17
|
+
vault_dir: `/home/test/.parachute/vault/${name}`,
|
|
18
|
+
vault_db: `/home/test/.parachute/vault/${name}/vault.db`,
|
|
19
|
+
vault_config: `/home/test/.parachute/vault/${name}/config.yaml`,
|
|
20
|
+
},
|
|
21
|
+
set_as_default: false,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
26
|
+
|
|
27
|
+
interface Harness {
|
|
28
|
+
dir: string;
|
|
29
|
+
manifestPath: string;
|
|
30
|
+
cleanup: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeHarness(): Harness {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-admin-vaults-"));
|
|
35
|
+
return {
|
|
36
|
+
dir,
|
|
37
|
+
manifestPath: join(dir, "services.json"),
|
|
38
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function adminToken(db: ReturnType<typeof openHubDb>): Promise<string> {
|
|
43
|
+
const { token } = await signAccessToken(db, {
|
|
44
|
+
sub: "user-admin",
|
|
45
|
+
scopes: [HOST_ADMIN_SCOPE, "vault:admin"],
|
|
46
|
+
audience: "operator",
|
|
47
|
+
clientId: "test-client",
|
|
48
|
+
issuer: ISSUER,
|
|
49
|
+
});
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readOnlyToken(db: ReturnType<typeof openHubDb>): Promise<string> {
|
|
54
|
+
const { token } = await signAccessToken(db, {
|
|
55
|
+
sub: "user-readonly",
|
|
56
|
+
scopes: ["vault:read"],
|
|
57
|
+
audience: "operator",
|
|
58
|
+
clientId: "test-client",
|
|
59
|
+
issuer: ISSUER,
|
|
60
|
+
});
|
|
61
|
+
return token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface CallOpts {
|
|
65
|
+
body?: unknown;
|
|
66
|
+
authHeader?: string | null;
|
|
67
|
+
contentType?: string | null;
|
|
68
|
+
manifestPath: string;
|
|
69
|
+
db: ReturnType<typeof openHubDb>;
|
|
70
|
+
runCommand?: (cmd: readonly string[]) => Promise<RunResult>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function call(opts: CallOpts): Promise<Response> {
|
|
74
|
+
const headers = new Headers();
|
|
75
|
+
if (opts.authHeader === undefined) {
|
|
76
|
+
headers.set("authorization", `Bearer ${await adminToken(opts.db)}`);
|
|
77
|
+
} else if (opts.authHeader !== null) {
|
|
78
|
+
headers.set("authorization", opts.authHeader);
|
|
79
|
+
}
|
|
80
|
+
if (opts.contentType === undefined) headers.set("content-type", "application/json");
|
|
81
|
+
else if (opts.contentType !== null) headers.set("content-type", opts.contentType);
|
|
82
|
+
|
|
83
|
+
const init: RequestInit = { method: "POST", headers };
|
|
84
|
+
if (opts.body !== undefined) {
|
|
85
|
+
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
|
|
86
|
+
}
|
|
87
|
+
const req = new Request(`${ISSUER}/vaults`, init);
|
|
88
|
+
return handleCreateVault(req, {
|
|
89
|
+
db: opts.db,
|
|
90
|
+
issuer: ISSUER,
|
|
91
|
+
manifestPath: opts.manifestPath,
|
|
92
|
+
...(opts.runCommand ? { runCommand: opts.runCommand } : {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("POST /vaults — auth", () => {
|
|
97
|
+
test("401 when Authorization header missing", async () => {
|
|
98
|
+
const h = makeHarness();
|
|
99
|
+
try {
|
|
100
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
101
|
+
try {
|
|
102
|
+
rotateSigningKey(db);
|
|
103
|
+
const res = await call({
|
|
104
|
+
db,
|
|
105
|
+
manifestPath: h.manifestPath,
|
|
106
|
+
authHeader: null,
|
|
107
|
+
body: { name: "work" },
|
|
108
|
+
});
|
|
109
|
+
expect(res.status).toBe(401);
|
|
110
|
+
} finally {
|
|
111
|
+
db.close();
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
h.cleanup();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("403 when token lacks parachute:host:admin scope", async () => {
|
|
119
|
+
const h = makeHarness();
|
|
120
|
+
try {
|
|
121
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
122
|
+
try {
|
|
123
|
+
rotateSigningKey(db);
|
|
124
|
+
const res = await call({
|
|
125
|
+
db,
|
|
126
|
+
manifestPath: h.manifestPath,
|
|
127
|
+
authHeader: `Bearer ${await readOnlyToken(db)}`,
|
|
128
|
+
body: { name: "work" },
|
|
129
|
+
});
|
|
130
|
+
expect(res.status).toBe(403);
|
|
131
|
+
} finally {
|
|
132
|
+
db.close();
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
h.cleanup();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("POST /vaults — body validation", () => {
|
|
141
|
+
test("400 when Content-Type is not application/json", async () => {
|
|
142
|
+
const h = makeHarness();
|
|
143
|
+
try {
|
|
144
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
145
|
+
try {
|
|
146
|
+
rotateSigningKey(db);
|
|
147
|
+
const res = await call({
|
|
148
|
+
db,
|
|
149
|
+
manifestPath: h.manifestPath,
|
|
150
|
+
contentType: "text/plain",
|
|
151
|
+
body: '{"name":"work"}',
|
|
152
|
+
});
|
|
153
|
+
expect(res.status).toBe(400);
|
|
154
|
+
} finally {
|
|
155
|
+
db.close();
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
h.cleanup();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("400 on malformed JSON", async () => {
|
|
163
|
+
const h = makeHarness();
|
|
164
|
+
try {
|
|
165
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
166
|
+
try {
|
|
167
|
+
rotateSigningKey(db);
|
|
168
|
+
const res = await call({
|
|
169
|
+
db,
|
|
170
|
+
manifestPath: h.manifestPath,
|
|
171
|
+
body: "not-json",
|
|
172
|
+
});
|
|
173
|
+
expect(res.status).toBe(400);
|
|
174
|
+
} finally {
|
|
175
|
+
db.close();
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
h.cleanup();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("400 when name is empty / missing / non-string", async () => {
|
|
183
|
+
const h = makeHarness();
|
|
184
|
+
try {
|
|
185
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
186
|
+
try {
|
|
187
|
+
rotateSigningKey(db);
|
|
188
|
+
for (const body of [{}, { name: "" }, { name: 42 }, { name: null }]) {
|
|
189
|
+
const res = await call({ db, manifestPath: h.manifestPath, body });
|
|
190
|
+
expect(res.status).toBe(400);
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
db.close();
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
h.cleanup();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("400 when name has invalid characters", async () => {
|
|
201
|
+
const h = makeHarness();
|
|
202
|
+
try {
|
|
203
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
204
|
+
try {
|
|
205
|
+
rotateSigningKey(db);
|
|
206
|
+
for (const name of ["my vault", "../etc", "foo/bar", "x.y", "a:b"]) {
|
|
207
|
+
const res = await call({ db, manifestPath: h.manifestPath, body: { name } });
|
|
208
|
+
expect(res.status).toBe(400);
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
db.close();
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
h.cleanup();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('400 when name is the reserved "list"', async () => {
|
|
219
|
+
const h = makeHarness();
|
|
220
|
+
try {
|
|
221
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
222
|
+
try {
|
|
223
|
+
rotateSigningKey(db);
|
|
224
|
+
const res = await call({ db, manifestPath: h.manifestPath, body: { name: "list" } });
|
|
225
|
+
expect(res.status).toBe(400);
|
|
226
|
+
} finally {
|
|
227
|
+
db.close();
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
h.cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('400 when name is "new" (would shadow SPA create route)', async () => {
|
|
235
|
+
// Without the reservation, a vault named "new" would capture
|
|
236
|
+
// `/vault/new` via the dynamic-proxy lookup and render the SPA's
|
|
237
|
+
// create-vault page unreachable.
|
|
238
|
+
const h = makeHarness();
|
|
239
|
+
try {
|
|
240
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
241
|
+
try {
|
|
242
|
+
rotateSigningKey(db);
|
|
243
|
+
const res = await call({ db, manifestPath: h.manifestPath, body: { name: "new" } });
|
|
244
|
+
expect(res.status).toBe(400);
|
|
245
|
+
const body = (await res.json()) as { error_description: string };
|
|
246
|
+
expect(body.error_description).toMatch(/reserved/i);
|
|
247
|
+
} finally {
|
|
248
|
+
db.close();
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
h.cleanup();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('400 when name is "assets" (would shadow SPA static bundle)', async () => {
|
|
256
|
+
// A vault named "assets" would capture `/vault/assets/*` and break
|
|
257
|
+
// SPA JS/CSS loading at both /vault and /hub mounts.
|
|
258
|
+
const h = makeHarness();
|
|
259
|
+
try {
|
|
260
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
261
|
+
try {
|
|
262
|
+
rotateSigningKey(db);
|
|
263
|
+
const res = await call({ db, manifestPath: h.manifestPath, body: { name: "assets" } });
|
|
264
|
+
expect(res.status).toBe(400);
|
|
265
|
+
const body = (await res.json()) as { error_description: string };
|
|
266
|
+
expect(body.error_description).toMatch(/reserved/i);
|
|
267
|
+
} finally {
|
|
268
|
+
db.close();
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
h.cleanup();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("POST /vaults — orchestration", () => {
|
|
277
|
+
test("201 on happy path with vault already registered → calls `parachute-vault create`", async () => {
|
|
278
|
+
const h = makeHarness();
|
|
279
|
+
try {
|
|
280
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
281
|
+
try {
|
|
282
|
+
rotateSigningKey(db);
|
|
283
|
+
// Seed services.json with the parachute-vault entry; vault is registered.
|
|
284
|
+
upsertService(
|
|
285
|
+
{
|
|
286
|
+
name: "parachute-vault",
|
|
287
|
+
port: 1940,
|
|
288
|
+
paths: ["/vault/default"],
|
|
289
|
+
health: "/health",
|
|
290
|
+
version: "0.3.5",
|
|
291
|
+
},
|
|
292
|
+
h.manifestPath,
|
|
293
|
+
);
|
|
294
|
+
const calls: Array<readonly string[]> = [];
|
|
295
|
+
const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
|
|
296
|
+
calls.push(cmd);
|
|
297
|
+
// Simulate successful CLI by adding the new path to the manifest.
|
|
298
|
+
upsertService(
|
|
299
|
+
{
|
|
300
|
+
name: "parachute-vault",
|
|
301
|
+
port: 1940,
|
|
302
|
+
paths: ["/vault/default", "/vault/work"],
|
|
303
|
+
health: "/health",
|
|
304
|
+
version: "0.3.5",
|
|
305
|
+
},
|
|
306
|
+
h.manifestPath,
|
|
307
|
+
);
|
|
308
|
+
return { exitCode: 0, stdout: vaultCreateJson("work"), stderr: "" };
|
|
309
|
+
};
|
|
310
|
+
const res = await call({
|
|
311
|
+
db,
|
|
312
|
+
manifestPath: h.manifestPath,
|
|
313
|
+
body: { name: "work" },
|
|
314
|
+
runCommand,
|
|
315
|
+
});
|
|
316
|
+
expect(res.status).toBe(201);
|
|
317
|
+
const body = (await res.json()) as { name: string; url: string; version: string };
|
|
318
|
+
expect(body.name).toBe("work");
|
|
319
|
+
expect(body.url).toBe(`${ISSUER}/vault/work`);
|
|
320
|
+
expect(body.version).toBe("0.3.5");
|
|
321
|
+
expect(calls).toEqual([["parachute-vault", "create", "work", "--json"]]);
|
|
322
|
+
} finally {
|
|
323
|
+
db.close();
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
h.cleanup();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("201 on bootstrap path (vault not yet registered) → calls `parachute install vault`", async () => {
|
|
331
|
+
const h = makeHarness();
|
|
332
|
+
try {
|
|
333
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
334
|
+
try {
|
|
335
|
+
rotateSigningKey(db);
|
|
336
|
+
// Empty manifest: vault NOT registered yet. The bootstrap path runs
|
|
337
|
+
// `parachute install vault`, which seeds the default vault. The
|
|
338
|
+
// `name` requested by the caller is honored on follow-up calls
|
|
339
|
+
// through the create-with-json branch (above); first-vault-on-host
|
|
340
|
+
// doesn't currently surface a token (install has no --json yet).
|
|
341
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
342
|
+
const calls: Array<readonly string[]> = [];
|
|
343
|
+
const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
|
|
344
|
+
calls.push(cmd);
|
|
345
|
+
upsertService(
|
|
346
|
+
{
|
|
347
|
+
name: "parachute-vault",
|
|
348
|
+
port: 1940,
|
|
349
|
+
paths: ["/vault/default"],
|
|
350
|
+
health: "/health",
|
|
351
|
+
version: "0.3.5",
|
|
352
|
+
},
|
|
353
|
+
h.manifestPath,
|
|
354
|
+
);
|
|
355
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
356
|
+
};
|
|
357
|
+
const res = await call({
|
|
358
|
+
db,
|
|
359
|
+
manifestPath: h.manifestPath,
|
|
360
|
+
body: { name: "default" },
|
|
361
|
+
runCommand,
|
|
362
|
+
});
|
|
363
|
+
expect(res.status).toBe(201);
|
|
364
|
+
expect(calls).toEqual([["parachute", "install", "vault"]]);
|
|
365
|
+
// Bootstrap path: response carries name/url/version, no token/paths
|
|
366
|
+
// (install doesn't emit JSON yet — known gap, follow-up issue).
|
|
367
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
368
|
+
expect(body.token).toBeUndefined();
|
|
369
|
+
expect(body.paths).toBeUndefined();
|
|
370
|
+
} finally {
|
|
371
|
+
db.close();
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
h.cleanup();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("201 response includes token + paths from `parachute-vault create --json` stdout", async () => {
|
|
379
|
+
const h = makeHarness();
|
|
380
|
+
try {
|
|
381
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
382
|
+
try {
|
|
383
|
+
rotateSigningKey(db);
|
|
384
|
+
upsertService(
|
|
385
|
+
{
|
|
386
|
+
name: "parachute-vault",
|
|
387
|
+
port: 1940,
|
|
388
|
+
paths: ["/vault/default"],
|
|
389
|
+
health: "/health",
|
|
390
|
+
version: "0.3.5",
|
|
391
|
+
},
|
|
392
|
+
h.manifestPath,
|
|
393
|
+
);
|
|
394
|
+
const runCommand = async (_cmd: readonly string[]): Promise<RunResult> => {
|
|
395
|
+
upsertService(
|
|
396
|
+
{
|
|
397
|
+
name: "parachute-vault",
|
|
398
|
+
port: 1940,
|
|
399
|
+
paths: ["/vault/default", "/vault/work"],
|
|
400
|
+
health: "/health",
|
|
401
|
+
version: "0.3.5",
|
|
402
|
+
},
|
|
403
|
+
h.manifestPath,
|
|
404
|
+
);
|
|
405
|
+
return {
|
|
406
|
+
exitCode: 0,
|
|
407
|
+
stdout: vaultCreateJson("work", "pvt_supersecret"),
|
|
408
|
+
stderr: "",
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
const res = await call({
|
|
412
|
+
db,
|
|
413
|
+
manifestPath: h.manifestPath,
|
|
414
|
+
body: { name: "work" },
|
|
415
|
+
runCommand,
|
|
416
|
+
});
|
|
417
|
+
expect(res.status).toBe(201);
|
|
418
|
+
const body = (await res.json()) as {
|
|
419
|
+
name: string;
|
|
420
|
+
token?: string;
|
|
421
|
+
paths?: { vault_dir: string; vault_db: string; vault_config: string };
|
|
422
|
+
};
|
|
423
|
+
expect(body.token).toBe("pvt_supersecret");
|
|
424
|
+
expect(body.paths).toEqual({
|
|
425
|
+
vault_dir: "/home/test/.parachute/vault/work",
|
|
426
|
+
vault_db: "/home/test/.parachute/vault/work/vault.db",
|
|
427
|
+
vault_config: "/home/test/.parachute/vault/work/config.yaml",
|
|
428
|
+
});
|
|
429
|
+
} finally {
|
|
430
|
+
db.close();
|
|
431
|
+
}
|
|
432
|
+
} finally {
|
|
433
|
+
h.cleanup();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("500 when `parachute-vault create --json` exits 0 but stdout is unparseable", async () => {
|
|
438
|
+
const h = makeHarness();
|
|
439
|
+
try {
|
|
440
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
441
|
+
try {
|
|
442
|
+
rotateSigningKey(db);
|
|
443
|
+
upsertService(
|
|
444
|
+
{
|
|
445
|
+
name: "parachute-vault",
|
|
446
|
+
port: 1940,
|
|
447
|
+
paths: ["/vault/default"],
|
|
448
|
+
health: "/health",
|
|
449
|
+
version: "0.3.5",
|
|
450
|
+
},
|
|
451
|
+
h.manifestPath,
|
|
452
|
+
);
|
|
453
|
+
const runCommand = async (): Promise<RunResult> => ({
|
|
454
|
+
exitCode: 0,
|
|
455
|
+
stdout: "this-is-not-json",
|
|
456
|
+
stderr: "",
|
|
457
|
+
});
|
|
458
|
+
const res = await call({
|
|
459
|
+
db,
|
|
460
|
+
manifestPath: h.manifestPath,
|
|
461
|
+
body: { name: "work" },
|
|
462
|
+
runCommand,
|
|
463
|
+
});
|
|
464
|
+
expect(res.status).toBe(500);
|
|
465
|
+
} finally {
|
|
466
|
+
db.close();
|
|
467
|
+
}
|
|
468
|
+
} finally {
|
|
469
|
+
h.cleanup();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("200 idempotent re-POST when vault already exists, no token in response", async () => {
|
|
474
|
+
const h = makeHarness();
|
|
475
|
+
try {
|
|
476
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
477
|
+
try {
|
|
478
|
+
rotateSigningKey(db);
|
|
479
|
+
upsertService(
|
|
480
|
+
{
|
|
481
|
+
name: "parachute-vault",
|
|
482
|
+
port: 1940,
|
|
483
|
+
paths: ["/vault/default", "/vault/work"],
|
|
484
|
+
health: "/health",
|
|
485
|
+
version: "0.3.5",
|
|
486
|
+
},
|
|
487
|
+
h.manifestPath,
|
|
488
|
+
);
|
|
489
|
+
let runCalled = false;
|
|
490
|
+
const runCommand = async (): Promise<RunResult> => {
|
|
491
|
+
runCalled = true;
|
|
492
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
493
|
+
};
|
|
494
|
+
const res = await call({
|
|
495
|
+
db,
|
|
496
|
+
manifestPath: h.manifestPath,
|
|
497
|
+
body: { name: "work" },
|
|
498
|
+
runCommand,
|
|
499
|
+
});
|
|
500
|
+
expect(res.status).toBe(200);
|
|
501
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
502
|
+
expect(body.name).toBe("work");
|
|
503
|
+
expect(body.url).toBe(`${ISSUER}/vault/work`);
|
|
504
|
+
// Token is single-emit at create time — re-POST never re-emits it.
|
|
505
|
+
expect(body.token).toBeUndefined();
|
|
506
|
+
expect(body.paths).toBeUndefined();
|
|
507
|
+
expect(runCalled).toBe(false);
|
|
508
|
+
} finally {
|
|
509
|
+
db.close();
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
h.cleanup();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("500 when CLI exits non-zero, error message includes full cmd + stderr tail", async () => {
|
|
517
|
+
const h = makeHarness();
|
|
518
|
+
try {
|
|
519
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
520
|
+
try {
|
|
521
|
+
rotateSigningKey(db);
|
|
522
|
+
upsertService(
|
|
523
|
+
{
|
|
524
|
+
name: "parachute-vault",
|
|
525
|
+
port: 1940,
|
|
526
|
+
paths: ["/vault/default"],
|
|
527
|
+
health: "/health",
|
|
528
|
+
version: "0.3.5",
|
|
529
|
+
},
|
|
530
|
+
h.manifestPath,
|
|
531
|
+
);
|
|
532
|
+
const runCommand = async (): Promise<RunResult> => ({
|
|
533
|
+
exitCode: 1,
|
|
534
|
+
stdout: "",
|
|
535
|
+
stderr: "vault create failed: name 'work' already exists\n",
|
|
536
|
+
});
|
|
537
|
+
const res = await call({
|
|
538
|
+
db,
|
|
539
|
+
manifestPath: h.manifestPath,
|
|
540
|
+
body: { name: "work" },
|
|
541
|
+
runCommand,
|
|
542
|
+
});
|
|
543
|
+
expect(res.status).toBe(500);
|
|
544
|
+
const body = (await res.json()) as { error_description: string };
|
|
545
|
+
// #97 NIT: full cmd in the error message (cmd.join), not just argv[0..1].
|
|
546
|
+
expect(body.error_description).toContain("parachute-vault create work --json");
|
|
547
|
+
// #97 NIT: stderr tail surfaced so the operator can see why.
|
|
548
|
+
expect(body.error_description).toContain("name 'work' already exists");
|
|
549
|
+
} finally {
|
|
550
|
+
db.close();
|
|
551
|
+
}
|
|
552
|
+
} finally {
|
|
553
|
+
h.cleanup();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("500 when CLI exits 0 but services.json doesn't reflect the new vault", async () => {
|
|
558
|
+
const h = makeHarness();
|
|
559
|
+
try {
|
|
560
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
561
|
+
try {
|
|
562
|
+
rotateSigningKey(db);
|
|
563
|
+
upsertService(
|
|
564
|
+
{
|
|
565
|
+
name: "parachute-vault",
|
|
566
|
+
port: 1940,
|
|
567
|
+
paths: ["/vault/default"],
|
|
568
|
+
health: "/health",
|
|
569
|
+
version: "0.3.5",
|
|
570
|
+
},
|
|
571
|
+
h.manifestPath,
|
|
572
|
+
);
|
|
573
|
+
const runCommand = async (): Promise<RunResult> => ({
|
|
574
|
+
exitCode: 0,
|
|
575
|
+
stdout: vaultCreateJson("work"),
|
|
576
|
+
stderr: "",
|
|
577
|
+
});
|
|
578
|
+
const res = await call({
|
|
579
|
+
db,
|
|
580
|
+
manifestPath: h.manifestPath,
|
|
581
|
+
body: { name: "work" },
|
|
582
|
+
runCommand,
|
|
583
|
+
});
|
|
584
|
+
expect(res.status).toBe(500);
|
|
585
|
+
} finally {
|
|
586
|
+
db.close();
|
|
587
|
+
}
|
|
588
|
+
} finally {
|
|
589
|
+
h.cleanup();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe("POST /vaults — method gating", () => {
|
|
595
|
+
test("405 on GET", async () => {
|
|
596
|
+
const h = makeHarness();
|
|
597
|
+
try {
|
|
598
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
599
|
+
try {
|
|
600
|
+
rotateSigningKey(db);
|
|
601
|
+
const req = new Request(`${ISSUER}/vaults`, { method: "GET" });
|
|
602
|
+
const res = await handleCreateVault(req, {
|
|
603
|
+
db,
|
|
604
|
+
issuer: ISSUER,
|
|
605
|
+
manifestPath: h.manifestPath,
|
|
606
|
+
});
|
|
607
|
+
expect(res.status).toBe(405);
|
|
608
|
+
} finally {
|
|
609
|
+
db.close();
|
|
610
|
+
}
|
|
611
|
+
} finally {
|
|
612
|
+
h.cleanup();
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
});
|