@openparachute/hub 0.5.7 → 0.5.10-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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -1,67 +1,22 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import {
|
|
7
|
-
handleAdminConfigGet,
|
|
8
|
-
handleAdminConfigPost,
|
|
9
7
|
handleAdminLoginGet,
|
|
10
8
|
handleAdminLoginPost,
|
|
11
9
|
handleAdminLogoutPost,
|
|
12
10
|
} from "../admin-handlers.ts";
|
|
13
11
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
14
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
15
|
-
import type { ConfigSchema, ModuleManifest } from "../module-manifest.ts";
|
|
16
13
|
import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
|
|
17
|
-
import type { ServicesManifest } from "../services-manifest.ts";
|
|
18
14
|
import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
|
|
19
15
|
import { createUser } from "../users.ts";
|
|
20
16
|
|
|
21
17
|
const TEST_CSRF = "csrf-handlers-test-token";
|
|
22
18
|
const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
|
|
23
19
|
|
|
24
|
-
const VAULT_SCHEMA: ConfigSchema = {
|
|
25
|
-
type: "object",
|
|
26
|
-
required: ["transcribe_provider"],
|
|
27
|
-
properties: {
|
|
28
|
-
transcribe_provider: {
|
|
29
|
-
type: "string",
|
|
30
|
-
description: "Speech-to-text backend.",
|
|
31
|
-
enum: ["openai", "deepgram", "groq"],
|
|
32
|
-
default: "openai",
|
|
33
|
-
},
|
|
34
|
-
max_tags_per_note: { type: "integer", default: 10 },
|
|
35
|
-
public: { type: "boolean", default: false },
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const VAULT_MANIFEST: ModuleManifest = {
|
|
40
|
-
name: "vault",
|
|
41
|
-
manifestName: "parachute-vault",
|
|
42
|
-
displayName: "Vault",
|
|
43
|
-
kind: "api",
|
|
44
|
-
port: 1940,
|
|
45
|
-
paths: ["/vault"],
|
|
46
|
-
health: "/health",
|
|
47
|
-
configSchema: VAULT_SCHEMA,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
function vaultServices(): ServicesManifest {
|
|
51
|
-
return {
|
|
52
|
-
services: [
|
|
53
|
-
{
|
|
54
|
-
name: "vault",
|
|
55
|
-
port: 1940,
|
|
56
|
-
paths: ["/vault"],
|
|
57
|
-
health: "/health",
|
|
58
|
-
version: "0.0.0",
|
|
59
|
-
installDir: "/fake/vault",
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
20
|
interface Harness {
|
|
66
21
|
db: Database;
|
|
67
22
|
configDir: string;
|
|
@@ -99,11 +54,6 @@ function formBody(values: Record<string, string>): {
|
|
|
99
54
|
};
|
|
100
55
|
}
|
|
101
56
|
|
|
102
|
-
function fakeReadManifest(installDir: string): Promise<ModuleManifest | null> {
|
|
103
|
-
if (installDir === "/fake/vault") return Promise.resolve(VAULT_MANIFEST);
|
|
104
|
-
return Promise.resolve(null);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
57
|
let harness: Harness;
|
|
108
58
|
beforeEach(() => {
|
|
109
59
|
harness = makeHarness();
|
|
@@ -125,20 +75,29 @@ describe("handleAdminLoginGet", () => {
|
|
|
125
75
|
});
|
|
126
76
|
|
|
127
77
|
test("echoes the next= query param into the form", async () => {
|
|
128
|
-
const req = new Request("http://hub.test/admin/login?next=/admin/
|
|
78
|
+
const req = new Request("http://hub.test/admin/login?next=/admin/permissions");
|
|
129
79
|
const res = handleAdminLoginGet(harness.db, req);
|
|
130
80
|
const html = await res.text();
|
|
131
|
-
expect(html).toContain('value="/admin/
|
|
81
|
+
expect(html).toContain('value="/admin/permissions"');
|
|
132
82
|
});
|
|
133
83
|
|
|
134
|
-
test("rewrites unsafe next= to /admin/
|
|
84
|
+
test("rewrites unsafe next= to /admin/vaults", async () => {
|
|
135
85
|
const req = new Request("http://hub.test/admin/login?next=https%3A%2F%2Fevil.example%2Fpwn");
|
|
136
86
|
const res = handleAdminLoginGet(harness.db, req);
|
|
137
87
|
const html = await res.text();
|
|
138
|
-
expect(html).toContain('value="/admin/
|
|
88
|
+
expect(html).toContain('value="/admin/vaults"');
|
|
139
89
|
expect(html).not.toContain("evil.example");
|
|
140
90
|
});
|
|
141
91
|
|
|
92
|
+
test("missing next= falls back to /admin/vaults (SPA home)", async () => {
|
|
93
|
+
// Post-SPA-rework default: the legacy `/admin/config` portal was retired,
|
|
94
|
+
// so the login form's hidden `next` defaults to the SPA's vault list.
|
|
95
|
+
const req = new Request("http://hub.test/admin/login");
|
|
96
|
+
const res = handleAdminLoginGet(harness.db, req);
|
|
97
|
+
const html = await res.text();
|
|
98
|
+
expect(html).toContain('value="/admin/vaults"');
|
|
99
|
+
});
|
|
100
|
+
|
|
142
101
|
test("hidden __csrf input value matches the freshly-minted cookie value (#113)", async () => {
|
|
143
102
|
const req = new Request("http://hub.test/admin/login");
|
|
144
103
|
const res = handleAdminLoginGet(harness.db, req);
|
|
@@ -161,7 +120,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
161
120
|
[CSRF_FIELD_NAME]: "wrong",
|
|
162
121
|
username: "admin",
|
|
163
122
|
password: "pw",
|
|
164
|
-
next: "/admin/
|
|
123
|
+
next: "/admin/vaults",
|
|
165
124
|
});
|
|
166
125
|
const req = new Request("http://hub.test/admin/login", {
|
|
167
126
|
method: "POST",
|
|
@@ -179,7 +138,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
179
138
|
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
180
139
|
username: "admin",
|
|
181
140
|
password: "wrong",
|
|
182
|
-
next: "/admin/
|
|
141
|
+
next: "/admin/vaults",
|
|
183
142
|
});
|
|
184
143
|
const req = new Request("http://hub.test/admin/login", {
|
|
185
144
|
method: "POST",
|
|
@@ -197,7 +156,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
197
156
|
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
198
157
|
username: "admin",
|
|
199
158
|
password: "pw",
|
|
200
|
-
next: "/admin/
|
|
159
|
+
next: "/admin/permissions",
|
|
201
160
|
});
|
|
202
161
|
const req = new Request("http://hub.test/admin/login", {
|
|
203
162
|
method: "POST",
|
|
@@ -206,7 +165,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
206
165
|
});
|
|
207
166
|
const res = await handleAdminLoginPost(harness.db, req);
|
|
208
167
|
expect(res.status).toBe(302);
|
|
209
|
-
expect(res.headers.get("location")).toBe("/admin/
|
|
168
|
+
expect(res.headers.get("location")).toBe("/admin/permissions");
|
|
210
169
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
|
|
211
170
|
});
|
|
212
171
|
|
|
@@ -225,7 +184,50 @@ describe("handleAdminLoginPost", () => {
|
|
|
225
184
|
});
|
|
226
185
|
const res = await handleAdminLoginPost(harness.db, req);
|
|
227
186
|
expect(res.status).toBe(302);
|
|
228
|
-
expect(res.headers.get("location")).toBe("/admin/
|
|
187
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("ignores a protocol-relative next= from the form (open-redirect defense)", async () => {
|
|
191
|
+
// Scheme-relative `//host/path` URLs would otherwise resolve to a
|
|
192
|
+
// different origin when followed by the browser. `safeNext` rejects them
|
|
193
|
+
// alongside scheme-absolute URLs; this test pins the POST path
|
|
194
|
+
// explicitly so future refactors of the redirect builder don't quietly
|
|
195
|
+
// re-open the open-redirect.
|
|
196
|
+
await createUser(harness.db, "admin", "pw");
|
|
197
|
+
const { body, headers } = formBody({
|
|
198
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
199
|
+
username: "admin",
|
|
200
|
+
password: "pw",
|
|
201
|
+
next: "//evil.example/pwn",
|
|
202
|
+
});
|
|
203
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
206
|
+
body,
|
|
207
|
+
});
|
|
208
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
209
|
+
expect(res.status).toBe(302);
|
|
210
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("missing next= lands the operator on /admin/vaults (SPA home)", async () => {
|
|
214
|
+
// Post-SPA-rework default: when the form omits `next`, login lands on
|
|
215
|
+
// the SPA's vault list. Previously the legacy `/admin/config` portal
|
|
216
|
+
// was the default; that page is retired and 301s to /admin/vaults.
|
|
217
|
+
await createUser(harness.db, "admin", "pw");
|
|
218
|
+
const { body, headers } = formBody({
|
|
219
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
220
|
+
username: "admin",
|
|
221
|
+
password: "pw",
|
|
222
|
+
});
|
|
223
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
226
|
+
body,
|
|
227
|
+
});
|
|
228
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
229
|
+
expect(res.status).toBe(302);
|
|
230
|
+
expect(res.headers.get("location")).toBe("/admin/vaults");
|
|
229
231
|
});
|
|
230
232
|
|
|
231
233
|
// hub#185 — per-IP rate-limit (5 attempts / 15 min) on POST /admin/login.
|
|
@@ -236,7 +238,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
236
238
|
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
237
239
|
username: "admin",
|
|
238
240
|
password,
|
|
239
|
-
next: "/admin/
|
|
241
|
+
next: "/admin/vaults",
|
|
240
242
|
});
|
|
241
243
|
return new Request("http://hub.test/admin/login", {
|
|
242
244
|
method: "POST",
|
|
@@ -270,7 +272,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
270
272
|
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
271
273
|
username: "admin",
|
|
272
274
|
password,
|
|
273
|
-
next: "/admin/
|
|
275
|
+
next: "/admin/vaults",
|
|
274
276
|
});
|
|
275
277
|
return new Request("http://hub.test/admin/login", {
|
|
276
278
|
method: "POST",
|
|
@@ -298,7 +300,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
298
300
|
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
299
301
|
username: "ghost",
|
|
300
302
|
password: "x",
|
|
301
|
-
next: "/admin/
|
|
303
|
+
next: "/admin/vaults",
|
|
302
304
|
});
|
|
303
305
|
return new Request("http://hub.test/admin/login", {
|
|
304
306
|
method: "POST",
|
|
@@ -330,7 +332,7 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
330
332
|
expect(res.headers.get("set-cookie")).toBeNull();
|
|
331
333
|
});
|
|
332
334
|
|
|
333
|
-
test("clears session cookie, deletes session row, and redirects to /
|
|
335
|
+
test("clears session cookie, deletes session row, and redirects to /login", async () => {
|
|
334
336
|
const user = await createUser(harness.db, "admin", "pw");
|
|
335
337
|
const session = createSession(harness.db, { userId: user.id });
|
|
336
338
|
const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(
|
|
@@ -338,14 +340,14 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
338
340
|
Math.floor(SESSION_TTL_MS / 1000),
|
|
339
341
|
)}`;
|
|
340
342
|
const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
|
|
341
|
-
const req = new Request("http://hub.test/
|
|
343
|
+
const req = new Request("http://hub.test/logout", {
|
|
342
344
|
method: "POST",
|
|
343
345
|
headers: { ...headers, cookie },
|
|
344
346
|
body,
|
|
345
347
|
});
|
|
346
348
|
const res = await handleAdminLogoutPost(harness.db, req);
|
|
347
349
|
expect(res.status).toBe(302);
|
|
348
|
-
expect(res.headers.get("location")).toBe("/
|
|
350
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
349
351
|
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
350
352
|
expect(setCookie).toContain("parachute_hub_session=;");
|
|
351
353
|
expect(setCookie).toContain("Max-Age=0");
|
|
@@ -354,269 +356,14 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
354
356
|
|
|
355
357
|
test("idempotent — clears cookie even with no active session", async () => {
|
|
356
358
|
const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
|
|
357
|
-
const req = new Request("http://hub.test/
|
|
359
|
+
const req = new Request("http://hub.test/logout", {
|
|
358
360
|
method: "POST",
|
|
359
361
|
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
360
362
|
body,
|
|
361
363
|
});
|
|
362
364
|
const res = await handleAdminLogoutPost(harness.db, req);
|
|
363
365
|
expect(res.status).toBe(302);
|
|
364
|
-
expect(res.headers.get("location")).toBe("/
|
|
366
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
365
367
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
|
|
366
368
|
});
|
|
367
369
|
});
|
|
368
|
-
|
|
369
|
-
describe("handleAdminConfigGet", () => {
|
|
370
|
-
test("redirects unauthenticated requests to /admin/login", async () => {
|
|
371
|
-
const req = new Request("http://hub.test/admin/config");
|
|
372
|
-
const res = await handleAdminConfigGet(harness.db, req);
|
|
373
|
-
expect(res.status).toBe(302);
|
|
374
|
-
expect(res.headers.get("location")).toBe("/admin/login?next=%2Fadmin%2Fconfig");
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
test("renders the empty-state when no module declares a configSchema", async () => {
|
|
378
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
379
|
-
const req = new Request("http://hub.test/admin/config", {
|
|
380
|
-
headers: { cookie },
|
|
381
|
-
});
|
|
382
|
-
const res = await handleAdminConfigGet(harness.db, req, {
|
|
383
|
-
loadServicesManifest: () => ({ services: [] }),
|
|
384
|
-
configDir: harness.configDir,
|
|
385
|
-
readManifest: fakeReadManifest,
|
|
386
|
-
});
|
|
387
|
-
expect(res.status).toBe(200);
|
|
388
|
-
const html = await res.text();
|
|
389
|
-
expect(html).toContain("No installed module declares");
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
test("renders one section per configurable module", async () => {
|
|
393
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
394
|
-
const req = new Request("http://hub.test/admin/config", {
|
|
395
|
-
headers: { cookie },
|
|
396
|
-
});
|
|
397
|
-
const res = await handleAdminConfigGet(harness.db, req, {
|
|
398
|
-
loadServicesManifest: vaultServices,
|
|
399
|
-
configDir: harness.configDir,
|
|
400
|
-
readManifest: fakeReadManifest,
|
|
401
|
-
});
|
|
402
|
-
expect(res.status).toBe(200);
|
|
403
|
-
const html = await res.text();
|
|
404
|
-
expect(html).toContain('id="module-vault"');
|
|
405
|
-
expect(html).toContain("transcribe_provider");
|
|
406
|
-
expect(html).toContain('action="/admin/config/vault"');
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
test("surfaces flash success message after a saved redirect", async () => {
|
|
410
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
411
|
-
const req = new Request("http://hub.test/admin/config?_status=saved&_module=vault", {
|
|
412
|
-
headers: { cookie },
|
|
413
|
-
});
|
|
414
|
-
const res = await handleAdminConfigGet(harness.db, req, {
|
|
415
|
-
loadServicesManifest: vaultServices,
|
|
416
|
-
configDir: harness.configDir,
|
|
417
|
-
readManifest: fakeReadManifest,
|
|
418
|
-
});
|
|
419
|
-
const html = await res.text();
|
|
420
|
-
expect(html).toContain("Saved and restarted Vault");
|
|
421
|
-
});
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
describe("handleAdminConfigPost", () => {
|
|
425
|
-
function postBody(values: Record<string, string>) {
|
|
426
|
-
return formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, ...values });
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
test("redirects unauthenticated requests to /admin/login", async () => {
|
|
430
|
-
const { body, headers } = postBody({ transcribe_provider: "openai" });
|
|
431
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
432
|
-
method: "POST",
|
|
433
|
-
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
434
|
-
body,
|
|
435
|
-
});
|
|
436
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
437
|
-
loadServicesManifest: vaultServices,
|
|
438
|
-
configDir: harness.configDir,
|
|
439
|
-
readManifest: fakeReadManifest,
|
|
440
|
-
});
|
|
441
|
-
expect(res.status).toBe(302);
|
|
442
|
-
expect(res.headers.get("location")).toContain("/admin/login");
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
test("returns 400 when the CSRF token is wrong", async () => {
|
|
446
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
447
|
-
const { body, headers } = formBody({
|
|
448
|
-
[CSRF_FIELD_NAME]: "wrong",
|
|
449
|
-
transcribe_provider: "openai",
|
|
450
|
-
});
|
|
451
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
452
|
-
method: "POST",
|
|
453
|
-
headers: { ...headers, cookie },
|
|
454
|
-
body,
|
|
455
|
-
});
|
|
456
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
457
|
-
loadServicesManifest: vaultServices,
|
|
458
|
-
configDir: harness.configDir,
|
|
459
|
-
readManifest: fakeReadManifest,
|
|
460
|
-
});
|
|
461
|
-
expect(res.status).toBe(400);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
test("returns 404 for unknown module names", async () => {
|
|
465
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
466
|
-
const { body, headers } = postBody({});
|
|
467
|
-
const req = new Request("http://hub.test/admin/config/nope", {
|
|
468
|
-
method: "POST",
|
|
469
|
-
headers: { ...headers, cookie },
|
|
470
|
-
body,
|
|
471
|
-
});
|
|
472
|
-
const res = await handleAdminConfigPost(harness.db, req, "nope", {
|
|
473
|
-
loadServicesManifest: vaultServices,
|
|
474
|
-
configDir: harness.configDir,
|
|
475
|
-
readManifest: fakeReadManifest,
|
|
476
|
-
});
|
|
477
|
-
expect(res.status).toBe(404);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
test("re-renders with field errors (422) when validation fails", async () => {
|
|
481
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
482
|
-
const restarts: string[] = [];
|
|
483
|
-
const { body, headers } = postBody({
|
|
484
|
-
transcribe_provider: "whisper", // not in enum
|
|
485
|
-
max_tags_per_note: "10",
|
|
486
|
-
});
|
|
487
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
488
|
-
method: "POST",
|
|
489
|
-
headers: { ...headers, cookie },
|
|
490
|
-
body,
|
|
491
|
-
});
|
|
492
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
493
|
-
loadServicesManifest: vaultServices,
|
|
494
|
-
configDir: harness.configDir,
|
|
495
|
-
readManifest: fakeReadManifest,
|
|
496
|
-
restartService: async (name) => {
|
|
497
|
-
restarts.push(name);
|
|
498
|
-
return 0;
|
|
499
|
-
},
|
|
500
|
-
});
|
|
501
|
-
expect(res.status).toBe(422);
|
|
502
|
-
const html = await res.text();
|
|
503
|
-
expect(html).toContain("must be one of");
|
|
504
|
-
expect(restarts).toEqual([]); // restart never called
|
|
505
|
-
// The on-disk config must not have been written.
|
|
506
|
-
expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(false);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
test("writes config + triggers restart + redirects with saved flash", async () => {
|
|
510
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
511
|
-
const restarts: string[] = [];
|
|
512
|
-
const { body, headers } = postBody({
|
|
513
|
-
transcribe_provider: "deepgram",
|
|
514
|
-
max_tags_per_note: "25",
|
|
515
|
-
// checkbox absent → public stays false
|
|
516
|
-
});
|
|
517
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
518
|
-
method: "POST",
|
|
519
|
-
headers: { ...headers, cookie },
|
|
520
|
-
body,
|
|
521
|
-
});
|
|
522
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
523
|
-
loadServicesManifest: vaultServices,
|
|
524
|
-
configDir: harness.configDir,
|
|
525
|
-
readManifest: fakeReadManifest,
|
|
526
|
-
restartService: async (name) => {
|
|
527
|
-
restarts.push(name);
|
|
528
|
-
return 0;
|
|
529
|
-
},
|
|
530
|
-
});
|
|
531
|
-
expect(res.status).toBe(302);
|
|
532
|
-
const location = res.headers.get("location") ?? "";
|
|
533
|
-
expect(location).toContain("/admin/config?");
|
|
534
|
-
expect(location).toContain("_status=saved");
|
|
535
|
-
expect(location).toContain("_module=vault");
|
|
536
|
-
expect(location).toContain("#module-vault");
|
|
537
|
-
expect(restarts).toEqual(["vault"]);
|
|
538
|
-
const written = JSON.parse(
|
|
539
|
-
readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
|
|
540
|
-
);
|
|
541
|
-
expect(written).toEqual({
|
|
542
|
-
transcribe_provider: "deepgram",
|
|
543
|
-
max_tags_per_note: 25,
|
|
544
|
-
public: false,
|
|
545
|
-
});
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
test("flashes saved-restart-failed when the restart returns non-zero", async () => {
|
|
549
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
550
|
-
const { body, headers } = postBody({
|
|
551
|
-
transcribe_provider: "openai",
|
|
552
|
-
max_tags_per_note: "5",
|
|
553
|
-
});
|
|
554
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
555
|
-
method: "POST",
|
|
556
|
-
headers: { ...headers, cookie },
|
|
557
|
-
body,
|
|
558
|
-
});
|
|
559
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
560
|
-
loadServicesManifest: vaultServices,
|
|
561
|
-
configDir: harness.configDir,
|
|
562
|
-
readManifest: fakeReadManifest,
|
|
563
|
-
restartService: async () => 1,
|
|
564
|
-
});
|
|
565
|
-
expect(res.status).toBe(302);
|
|
566
|
-
const location = res.headers.get("location") ?? "";
|
|
567
|
-
expect(location).toContain("_status=saved-restart-failed");
|
|
568
|
-
// Config was still written before the restart attempt.
|
|
569
|
-
expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(true);
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
test("flashes saved-restart-failed with err detail when restart throws", async () => {
|
|
573
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
574
|
-
const { body, headers } = postBody({
|
|
575
|
-
transcribe_provider: "openai",
|
|
576
|
-
max_tags_per_note: "5",
|
|
577
|
-
});
|
|
578
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
579
|
-
method: "POST",
|
|
580
|
-
headers: { ...headers, cookie },
|
|
581
|
-
body,
|
|
582
|
-
});
|
|
583
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
584
|
-
loadServicesManifest: vaultServices,
|
|
585
|
-
configDir: harness.configDir,
|
|
586
|
-
readManifest: fakeReadManifest,
|
|
587
|
-
restartService: async () => {
|
|
588
|
-
throw new Error("launchctl unavailable");
|
|
589
|
-
},
|
|
590
|
-
});
|
|
591
|
-
expect(res.status).toBe(302);
|
|
592
|
-
const location = res.headers.get("location") ?? "";
|
|
593
|
-
expect(location).toContain("_status=saved-restart-failed");
|
|
594
|
-
const errParam = new URL(location, "http://hub.test").searchParams.get("_err") ?? "";
|
|
595
|
-
expect(errParam).toContain("launchctl unavailable");
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
test("checkbox-on translates to `public: true` in the written config", async () => {
|
|
599
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
600
|
-
const { body, headers } = postBody({
|
|
601
|
-
transcribe_provider: "openai",
|
|
602
|
-
max_tags_per_note: "5",
|
|
603
|
-
public: "true",
|
|
604
|
-
});
|
|
605
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
606
|
-
method: "POST",
|
|
607
|
-
headers: { ...headers, cookie },
|
|
608
|
-
body,
|
|
609
|
-
});
|
|
610
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
611
|
-
loadServicesManifest: vaultServices,
|
|
612
|
-
configDir: harness.configDir,
|
|
613
|
-
readManifest: fakeReadManifest,
|
|
614
|
-
restartService: async () => 0,
|
|
615
|
-
});
|
|
616
|
-
expect(res.status).toBe(302);
|
|
617
|
-
const written = JSON.parse(
|
|
618
|
-
readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
|
|
619
|
-
);
|
|
620
|
-
expect(written.public).toBe(true);
|
|
621
|
-
});
|
|
622
|
-
});
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* Tests for the SPA's session→bearer mint endpoint. Covers:
|
|
3
3
|
* - 401 when no admin session cookie is present.
|
|
4
4
|
* - 401 when the cookie names a deleted/expired session.
|
|
5
|
-
* - 200 + JWT carrying parachute:host:admin
|
|
5
|
+
* - 200 + JWT carrying parachute:host:admin AND parachute:host:auth.
|
|
6
6
|
* - Token validates against the hub's own keys and the configured issuer.
|
|
7
7
|
* - Method-not-allowed on POST.
|
|
8
|
+
* - End-to-end regression: the minted JWT actually unlocks the new
|
|
9
|
+
* `/api/auth/tokens` endpoint (hub#212 Phase 2 backend) — the bug from
|
|
10
|
+
* end-to-end testing that motivated adding `parachute:host:auth` here.
|
|
8
11
|
*/
|
|
9
12
|
import type { Database } from "bun:sqlite";
|
|
10
13
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
@@ -12,9 +15,11 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
12
15
|
import { tmpdir } from "node:os";
|
|
13
16
|
import { join } from "node:path";
|
|
14
17
|
import { HOST_ADMIN_TOKEN_TTL_SECONDS, handleHostAdminToken } from "../admin-host-admin-token.ts";
|
|
18
|
+
import { handleApiTokens } from "../api-tokens.ts";
|
|
15
19
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
20
|
import { validateAccessToken } from "../jwt-sign.ts";
|
|
17
21
|
import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
|
|
22
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
18
23
|
import { createUser } from "../users.ts";
|
|
19
24
|
|
|
20
25
|
const ISSUER = "https://hub.test";
|
|
@@ -82,8 +87,9 @@ describe("handleHostAdminToken", () => {
|
|
|
82
87
|
expect(res.status).toBe(405);
|
|
83
88
|
});
|
|
84
89
|
|
|
85
|
-
test("200 mints a JWT carrying parachute:host:admin and the configured issuer", async () => {
|
|
90
|
+
test("200 mints a JWT carrying parachute:host:admin + parachute:host:auth and the configured issuer", async () => {
|
|
86
91
|
const { cookie, userId } = await withSession();
|
|
92
|
+
rotateSigningKey(harness.db);
|
|
87
93
|
const req = new Request(`${ISSUER}/admin/host-admin-token`, {
|
|
88
94
|
headers: { cookie },
|
|
89
95
|
});
|
|
@@ -96,7 +102,10 @@ describe("handleHostAdminToken", () => {
|
|
|
96
102
|
expires_at: string;
|
|
97
103
|
scopes: string[];
|
|
98
104
|
};
|
|
99
|
-
|
|
105
|
+
// Both scopes — the SPA now needs `:host:auth` for the hub#212 Phase 2
|
|
106
|
+
// token-registry endpoints alongside the existing `:host:admin` for
|
|
107
|
+
// vault provisioning + grant management.
|
|
108
|
+
expect(body.scopes).toEqual(["parachute:host:admin", "parachute:host:auth"]);
|
|
100
109
|
expect(body.token.length).toBeGreaterThan(20);
|
|
101
110
|
|
|
102
111
|
// expires_at is roughly TTL_SECONDS in the future.
|
|
@@ -110,6 +119,45 @@ describe("handleHostAdminToken", () => {
|
|
|
110
119
|
expect(validated.payload.sub).toBe(userId);
|
|
111
120
|
expect(validated.payload.iss).toBe(ISSUER);
|
|
112
121
|
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
113
|
-
|
|
122
|
+
const scopes = scopeClaim.split(/\s+/);
|
|
123
|
+
expect(scopes).toContain("parachute:host:admin");
|
|
124
|
+
expect(scopes).toContain("parachute:host:auth");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Regression for the end-to-end bug that motivated adding `:host:auth`
|
|
128
|
+
// here: the SPA's session-bearer was rejected by `/api/auth/tokens` (and
|
|
129
|
+
// its peers) because it carried `:host:admin` only. This test mints
|
|
130
|
+
// through the SPA path and exercises one of the new endpoints
|
|
131
|
+
// end-to-end — the Phase 2 backend tests only minted operator-style
|
|
132
|
+
// tokens with `:host:auth` directly, leaving the SPA-flow gap uncaught.
|
|
133
|
+
test("regression: the minted SPA bearer is accepted by /api/auth/tokens", async () => {
|
|
134
|
+
const { cookie } = await withSession();
|
|
135
|
+
rotateSigningKey(harness.db);
|
|
136
|
+
|
|
137
|
+
// Step 1: SPA grabs its bearer via the cookie path.
|
|
138
|
+
const mintRes = await handleHostAdminToken(
|
|
139
|
+
new Request(`${ISSUER}/admin/host-admin-token`, { headers: { cookie } }),
|
|
140
|
+
{ db: harness.db, issuer: ISSUER },
|
|
141
|
+
);
|
|
142
|
+
expect(mintRes.status).toBe(200);
|
|
143
|
+
const { token } = (await mintRes.json()) as { token: string };
|
|
144
|
+
|
|
145
|
+
// Step 2: SPA hits /api/auth/tokens with that bearer. Pre-fix this
|
|
146
|
+
// returned 403 `bearer token lacks parachute:host:auth`; post-fix it
|
|
147
|
+
// returns 200 with the (empty-by-default) tokens list.
|
|
148
|
+
const tokensRes = await handleApiTokens(
|
|
149
|
+
new Request(`${ISSUER}/api/auth/tokens`, {
|
|
150
|
+
method: "GET",
|
|
151
|
+
headers: { authorization: `Bearer ${token}` },
|
|
152
|
+
}),
|
|
153
|
+
{ db: harness.db, issuer: ISSUER },
|
|
154
|
+
);
|
|
155
|
+
expect(tokensRes.status).toBe(200);
|
|
156
|
+
const tokensBody = (await tokensRes.json()) as {
|
|
157
|
+
tokens: unknown[];
|
|
158
|
+
next_cursor: string | null;
|
|
159
|
+
};
|
|
160
|
+
expect(Array.isArray(tokensBody.tokens)).toBe(true);
|
|
161
|
+
expect(tokensBody.next_cursor).toBeNull();
|
|
114
162
|
});
|
|
115
163
|
});
|