@openparachute/hub 0.5.2 → 0.5.9-rc.6
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 +159 -320
- 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 +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- 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/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- 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/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -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,66 +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
|
|
16
|
-
import type { ServicesManifest } from "../services-manifest.ts";
|
|
13
|
+
import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
|
|
17
14
|
import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
|
|
18
15
|
import { createUser } from "../users.ts";
|
|
19
16
|
|
|
20
17
|
const TEST_CSRF = "csrf-handlers-test-token";
|
|
21
18
|
const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
|
|
22
19
|
|
|
23
|
-
const VAULT_SCHEMA: ConfigSchema = {
|
|
24
|
-
type: "object",
|
|
25
|
-
required: ["transcribe_provider"],
|
|
26
|
-
properties: {
|
|
27
|
-
transcribe_provider: {
|
|
28
|
-
type: "string",
|
|
29
|
-
description: "Speech-to-text backend.",
|
|
30
|
-
enum: ["openai", "deepgram", "groq"],
|
|
31
|
-
default: "openai",
|
|
32
|
-
},
|
|
33
|
-
max_tags_per_note: { type: "integer", default: 10 },
|
|
34
|
-
public: { type: "boolean", default: false },
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const VAULT_MANIFEST: ModuleManifest = {
|
|
39
|
-
name: "vault",
|
|
40
|
-
manifestName: "parachute-vault",
|
|
41
|
-
displayName: "Vault",
|
|
42
|
-
kind: "api",
|
|
43
|
-
port: 1940,
|
|
44
|
-
paths: ["/vault"],
|
|
45
|
-
health: "/health",
|
|
46
|
-
configSchema: VAULT_SCHEMA,
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
function vaultServices(): ServicesManifest {
|
|
50
|
-
return {
|
|
51
|
-
services: [
|
|
52
|
-
{
|
|
53
|
-
name: "vault",
|
|
54
|
-
port: 1940,
|
|
55
|
-
paths: ["/vault"],
|
|
56
|
-
health: "/health",
|
|
57
|
-
version: "0.0.0",
|
|
58
|
-
installDir: "/fake/vault",
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
20
|
interface Harness {
|
|
65
21
|
db: Database;
|
|
66
22
|
configDir: string;
|
|
@@ -98,14 +54,13 @@ function formBody(values: Record<string, string>): {
|
|
|
98
54
|
};
|
|
99
55
|
}
|
|
100
56
|
|
|
101
|
-
function fakeReadManifest(installDir: string): Promise<ModuleManifest | null> {
|
|
102
|
-
if (installDir === "/fake/vault") return Promise.resolve(VAULT_MANIFEST);
|
|
103
|
-
return Promise.resolve(null);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
57
|
let harness: Harness;
|
|
107
58
|
beforeEach(() => {
|
|
108
59
|
harness = makeHarness();
|
|
60
|
+
// Per-test rate-limit state — login tests share the UNKNOWN_IP sentinel
|
|
61
|
+
// bucket since they don't set CF-Connecting-IP, so without a reset the
|
|
62
|
+
// 6th test in this file would 429 spuriously.
|
|
63
|
+
resetRateLimit();
|
|
109
64
|
});
|
|
110
65
|
afterEach(() => {
|
|
111
66
|
harness.cleanup();
|
|
@@ -120,20 +75,29 @@ describe("handleAdminLoginGet", () => {
|
|
|
120
75
|
});
|
|
121
76
|
|
|
122
77
|
test("echoes the next= query param into the form", async () => {
|
|
123
|
-
const req = new Request("http://hub.test/admin/login?next=/admin/
|
|
78
|
+
const req = new Request("http://hub.test/admin/login?next=/admin/permissions");
|
|
124
79
|
const res = handleAdminLoginGet(harness.db, req);
|
|
125
80
|
const html = await res.text();
|
|
126
|
-
expect(html).toContain('value="/admin/
|
|
81
|
+
expect(html).toContain('value="/admin/permissions"');
|
|
127
82
|
});
|
|
128
83
|
|
|
129
|
-
test("rewrites unsafe next= to /admin/
|
|
84
|
+
test("rewrites unsafe next= to /admin/vaults", async () => {
|
|
130
85
|
const req = new Request("http://hub.test/admin/login?next=https%3A%2F%2Fevil.example%2Fpwn");
|
|
131
86
|
const res = handleAdminLoginGet(harness.db, req);
|
|
132
87
|
const html = await res.text();
|
|
133
|
-
expect(html).toContain('value="/admin/
|
|
88
|
+
expect(html).toContain('value="/admin/vaults"');
|
|
134
89
|
expect(html).not.toContain("evil.example");
|
|
135
90
|
});
|
|
136
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
|
+
|
|
137
101
|
test("hidden __csrf input value matches the freshly-minted cookie value (#113)", async () => {
|
|
138
102
|
const req = new Request("http://hub.test/admin/login");
|
|
139
103
|
const res = handleAdminLoginGet(harness.db, req);
|
|
@@ -156,7 +120,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
156
120
|
[CSRF_FIELD_NAME]: "wrong",
|
|
157
121
|
username: "admin",
|
|
158
122
|
password: "pw",
|
|
159
|
-
next: "/admin/
|
|
123
|
+
next: "/admin/vaults",
|
|
160
124
|
});
|
|
161
125
|
const req = new Request("http://hub.test/admin/login", {
|
|
162
126
|
method: "POST",
|
|
@@ -174,7 +138,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
174
138
|
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
175
139
|
username: "admin",
|
|
176
140
|
password: "wrong",
|
|
177
|
-
next: "/admin/
|
|
141
|
+
next: "/admin/vaults",
|
|
178
142
|
});
|
|
179
143
|
const req = new Request("http://hub.test/admin/login", {
|
|
180
144
|
method: "POST",
|
|
@@ -192,7 +156,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
192
156
|
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
193
157
|
username: "admin",
|
|
194
158
|
password: "pw",
|
|
195
|
-
next: "/admin/
|
|
159
|
+
next: "/admin/permissions",
|
|
196
160
|
});
|
|
197
161
|
const req = new Request("http://hub.test/admin/login", {
|
|
198
162
|
method: "POST",
|
|
@@ -201,7 +165,7 @@ describe("handleAdminLoginPost", () => {
|
|
|
201
165
|
});
|
|
202
166
|
const res = await handleAdminLoginPost(harness.db, req);
|
|
203
167
|
expect(res.status).toBe(302);
|
|
204
|
-
expect(res.headers.get("location")).toBe("/admin/
|
|
168
|
+
expect(res.headers.get("location")).toBe("/admin/permissions");
|
|
205
169
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
|
|
206
170
|
});
|
|
207
171
|
|
|
@@ -220,7 +184,137 @@ describe("handleAdminLoginPost", () => {
|
|
|
220
184
|
});
|
|
221
185
|
const res = await handleAdminLoginPost(harness.db, req);
|
|
222
186
|
expect(res.status).toBe(302);
|
|
223
|
-
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");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// hub#185 — per-IP rate-limit (5 attempts / 15 min) on POST /admin/login.
|
|
234
|
+
test("6 rapid POSTs from same IP get 200/401×4/429 and the 429 carries Retry-After", async () => {
|
|
235
|
+
await createUser(harness.db, "admin", "correct-pw");
|
|
236
|
+
const buildReq = (password: string) => {
|
|
237
|
+
const { body, headers } = formBody({
|
|
238
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
239
|
+
username: "admin",
|
|
240
|
+
password,
|
|
241
|
+
next: "/admin/vaults",
|
|
242
|
+
});
|
|
243
|
+
return new Request("http://hub.test/admin/login", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.42" },
|
|
246
|
+
body,
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
// First attempt: correct password → 302. Counts as attempt #1.
|
|
250
|
+
const first = await handleAdminLoginPost(harness.db, buildReq("correct-pw"));
|
|
251
|
+
expect(first.status).toBe(302);
|
|
252
|
+
// Attempts 2–5: wrong password → 401 each.
|
|
253
|
+
for (let i = 2; i <= 5; i++) {
|
|
254
|
+
const r = await handleAdminLoginPost(harness.db, buildReq("wrong"));
|
|
255
|
+
expect(r.status).toBe(401);
|
|
256
|
+
}
|
|
257
|
+
// Attempt 6: rate-limit fires before credential check → 429 + Retry-After.
|
|
258
|
+
const denied = await handleAdminLoginPost(harness.db, buildReq("wrong"));
|
|
259
|
+
expect(denied.status).toBe(429);
|
|
260
|
+
const retryAfter = denied.headers.get("retry-after");
|
|
261
|
+
expect(retryAfter).not.toBeNull();
|
|
262
|
+
const seconds = Number(retryAfter);
|
|
263
|
+
expect(seconds).toBeGreaterThan(0);
|
|
264
|
+
// Window is 15 min = 900s, so retry-after sits in (0, 900].
|
|
265
|
+
expect(seconds).toBeLessThanOrEqual(900);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("rate-limit is per-IP: a different IP can still log in after another's bucket fills", async () => {
|
|
269
|
+
await createUser(harness.db, "admin", "pw");
|
|
270
|
+
const buildReq = (ip: string, password: string) => {
|
|
271
|
+
const { body, headers } = formBody({
|
|
272
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
273
|
+
username: "admin",
|
|
274
|
+
password,
|
|
275
|
+
next: "/admin/vaults",
|
|
276
|
+
});
|
|
277
|
+
return new Request("http://hub.test/admin/login", {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": ip },
|
|
280
|
+
body,
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
// Exhaust ip-a's bucket with 5 wrong-password attempts, then confirm 429.
|
|
284
|
+
for (let i = 0; i < 5; i++) {
|
|
285
|
+
await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
|
|
286
|
+
}
|
|
287
|
+
const aDenied = await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
|
|
288
|
+
expect(aDenied.status).toBe(429);
|
|
289
|
+
// Different IP: fresh bucket, correct credentials → 302.
|
|
290
|
+
const bOk = await handleAdminLoginPost(harness.db, buildReq("198.51.100.99", "pw"));
|
|
291
|
+
expect(bOk.status).toBe(302);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("rate-limit fires before credential check (denied request never touches DB)", async () => {
|
|
295
|
+
// No user exists in the harness DB. First 5 attempts should be 401
|
|
296
|
+
// ("Invalid credentials" — no such user). 6th should be 429 with the
|
|
297
|
+
// rate-limit body, NOT a credential-failure body.
|
|
298
|
+
const buildReq = () => {
|
|
299
|
+
const { body, headers } = formBody({
|
|
300
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
301
|
+
username: "ghost",
|
|
302
|
+
password: "x",
|
|
303
|
+
next: "/admin/vaults",
|
|
304
|
+
});
|
|
305
|
+
return new Request("http://hub.test/admin/login", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.99" },
|
|
308
|
+
body,
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
for (let i = 0; i < 5; i++) {
|
|
312
|
+
const r = await handleAdminLoginPost(harness.db, buildReq());
|
|
313
|
+
expect(r.status).toBe(401);
|
|
314
|
+
}
|
|
315
|
+
const denied = await handleAdminLoginPost(harness.db, buildReq());
|
|
316
|
+
expect(denied.status).toBe(429);
|
|
317
|
+
expect(await denied.text()).toContain("Too many login attempts");
|
|
224
318
|
});
|
|
225
319
|
});
|
|
226
320
|
|
|
@@ -238,7 +332,7 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
238
332
|
expect(res.headers.get("set-cookie")).toBeNull();
|
|
239
333
|
});
|
|
240
334
|
|
|
241
|
-
test("clears session cookie, deletes session row, and redirects to /
|
|
335
|
+
test("clears session cookie, deletes session row, and redirects to /login", async () => {
|
|
242
336
|
const user = await createUser(harness.db, "admin", "pw");
|
|
243
337
|
const session = createSession(harness.db, { userId: user.id });
|
|
244
338
|
const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(
|
|
@@ -246,14 +340,14 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
246
340
|
Math.floor(SESSION_TTL_MS / 1000),
|
|
247
341
|
)}`;
|
|
248
342
|
const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
|
|
249
|
-
const req = new Request("http://hub.test/
|
|
343
|
+
const req = new Request("http://hub.test/logout", {
|
|
250
344
|
method: "POST",
|
|
251
345
|
headers: { ...headers, cookie },
|
|
252
346
|
body,
|
|
253
347
|
});
|
|
254
348
|
const res = await handleAdminLogoutPost(harness.db, req);
|
|
255
349
|
expect(res.status).toBe(302);
|
|
256
|
-
expect(res.headers.get("location")).toBe("/
|
|
350
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
257
351
|
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
258
352
|
expect(setCookie).toContain("parachute_hub_session=;");
|
|
259
353
|
expect(setCookie).toContain("Max-Age=0");
|
|
@@ -262,269 +356,14 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
262
356
|
|
|
263
357
|
test("idempotent — clears cookie even with no active session", async () => {
|
|
264
358
|
const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
|
|
265
|
-
const req = new Request("http://hub.test/
|
|
359
|
+
const req = new Request("http://hub.test/logout", {
|
|
266
360
|
method: "POST",
|
|
267
361
|
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
268
362
|
body,
|
|
269
363
|
});
|
|
270
364
|
const res = await handleAdminLogoutPost(harness.db, req);
|
|
271
365
|
expect(res.status).toBe(302);
|
|
272
|
-
expect(res.headers.get("location")).toBe("/
|
|
366
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
273
367
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
|
|
274
368
|
});
|
|
275
369
|
});
|
|
276
|
-
|
|
277
|
-
describe("handleAdminConfigGet", () => {
|
|
278
|
-
test("redirects unauthenticated requests to /admin/login", async () => {
|
|
279
|
-
const req = new Request("http://hub.test/admin/config");
|
|
280
|
-
const res = await handleAdminConfigGet(harness.db, req);
|
|
281
|
-
expect(res.status).toBe(302);
|
|
282
|
-
expect(res.headers.get("location")).toBe("/admin/login?next=%2Fadmin%2Fconfig");
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test("renders the empty-state when no module declares a configSchema", async () => {
|
|
286
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
287
|
-
const req = new Request("http://hub.test/admin/config", {
|
|
288
|
-
headers: { cookie },
|
|
289
|
-
});
|
|
290
|
-
const res = await handleAdminConfigGet(harness.db, req, {
|
|
291
|
-
loadServicesManifest: () => ({ services: [] }),
|
|
292
|
-
configDir: harness.configDir,
|
|
293
|
-
readManifest: fakeReadManifest,
|
|
294
|
-
});
|
|
295
|
-
expect(res.status).toBe(200);
|
|
296
|
-
const html = await res.text();
|
|
297
|
-
expect(html).toContain("No installed module declares");
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
test("renders one section per configurable module", async () => {
|
|
301
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
302
|
-
const req = new Request("http://hub.test/admin/config", {
|
|
303
|
-
headers: { cookie },
|
|
304
|
-
});
|
|
305
|
-
const res = await handleAdminConfigGet(harness.db, req, {
|
|
306
|
-
loadServicesManifest: vaultServices,
|
|
307
|
-
configDir: harness.configDir,
|
|
308
|
-
readManifest: fakeReadManifest,
|
|
309
|
-
});
|
|
310
|
-
expect(res.status).toBe(200);
|
|
311
|
-
const html = await res.text();
|
|
312
|
-
expect(html).toContain('id="module-vault"');
|
|
313
|
-
expect(html).toContain("transcribe_provider");
|
|
314
|
-
expect(html).toContain('action="/admin/config/vault"');
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("surfaces flash success message after a saved redirect", async () => {
|
|
318
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
319
|
-
const req = new Request("http://hub.test/admin/config?_status=saved&_module=vault", {
|
|
320
|
-
headers: { cookie },
|
|
321
|
-
});
|
|
322
|
-
const res = await handleAdminConfigGet(harness.db, req, {
|
|
323
|
-
loadServicesManifest: vaultServices,
|
|
324
|
-
configDir: harness.configDir,
|
|
325
|
-
readManifest: fakeReadManifest,
|
|
326
|
-
});
|
|
327
|
-
const html = await res.text();
|
|
328
|
-
expect(html).toContain("Saved and restarted Vault");
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
describe("handleAdminConfigPost", () => {
|
|
333
|
-
function postBody(values: Record<string, string>) {
|
|
334
|
-
return formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, ...values });
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
test("redirects unauthenticated requests to /admin/login", async () => {
|
|
338
|
-
const { body, headers } = postBody({ transcribe_provider: "openai" });
|
|
339
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
340
|
-
method: "POST",
|
|
341
|
-
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
342
|
-
body,
|
|
343
|
-
});
|
|
344
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
345
|
-
loadServicesManifest: vaultServices,
|
|
346
|
-
configDir: harness.configDir,
|
|
347
|
-
readManifest: fakeReadManifest,
|
|
348
|
-
});
|
|
349
|
-
expect(res.status).toBe(302);
|
|
350
|
-
expect(res.headers.get("location")).toContain("/admin/login");
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
test("returns 400 when the CSRF token is wrong", async () => {
|
|
354
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
355
|
-
const { body, headers } = formBody({
|
|
356
|
-
[CSRF_FIELD_NAME]: "wrong",
|
|
357
|
-
transcribe_provider: "openai",
|
|
358
|
-
});
|
|
359
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
360
|
-
method: "POST",
|
|
361
|
-
headers: { ...headers, cookie },
|
|
362
|
-
body,
|
|
363
|
-
});
|
|
364
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
365
|
-
loadServicesManifest: vaultServices,
|
|
366
|
-
configDir: harness.configDir,
|
|
367
|
-
readManifest: fakeReadManifest,
|
|
368
|
-
});
|
|
369
|
-
expect(res.status).toBe(400);
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
test("returns 404 for unknown module names", async () => {
|
|
373
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
374
|
-
const { body, headers } = postBody({});
|
|
375
|
-
const req = new Request("http://hub.test/admin/config/nope", {
|
|
376
|
-
method: "POST",
|
|
377
|
-
headers: { ...headers, cookie },
|
|
378
|
-
body,
|
|
379
|
-
});
|
|
380
|
-
const res = await handleAdminConfigPost(harness.db, req, "nope", {
|
|
381
|
-
loadServicesManifest: vaultServices,
|
|
382
|
-
configDir: harness.configDir,
|
|
383
|
-
readManifest: fakeReadManifest,
|
|
384
|
-
});
|
|
385
|
-
expect(res.status).toBe(404);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
test("re-renders with field errors (422) when validation fails", async () => {
|
|
389
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
390
|
-
const restarts: string[] = [];
|
|
391
|
-
const { body, headers } = postBody({
|
|
392
|
-
transcribe_provider: "whisper", // not in enum
|
|
393
|
-
max_tags_per_note: "10",
|
|
394
|
-
});
|
|
395
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
396
|
-
method: "POST",
|
|
397
|
-
headers: { ...headers, cookie },
|
|
398
|
-
body,
|
|
399
|
-
});
|
|
400
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
401
|
-
loadServicesManifest: vaultServices,
|
|
402
|
-
configDir: harness.configDir,
|
|
403
|
-
readManifest: fakeReadManifest,
|
|
404
|
-
restartService: async (name) => {
|
|
405
|
-
restarts.push(name);
|
|
406
|
-
return 0;
|
|
407
|
-
},
|
|
408
|
-
});
|
|
409
|
-
expect(res.status).toBe(422);
|
|
410
|
-
const html = await res.text();
|
|
411
|
-
expect(html).toContain("must be one of");
|
|
412
|
-
expect(restarts).toEqual([]); // restart never called
|
|
413
|
-
// The on-disk config must not have been written.
|
|
414
|
-
expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(false);
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
test("writes config + triggers restart + redirects with saved flash", async () => {
|
|
418
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
419
|
-
const restarts: string[] = [];
|
|
420
|
-
const { body, headers } = postBody({
|
|
421
|
-
transcribe_provider: "deepgram",
|
|
422
|
-
max_tags_per_note: "25",
|
|
423
|
-
// checkbox absent → public stays false
|
|
424
|
-
});
|
|
425
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
426
|
-
method: "POST",
|
|
427
|
-
headers: { ...headers, cookie },
|
|
428
|
-
body,
|
|
429
|
-
});
|
|
430
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
431
|
-
loadServicesManifest: vaultServices,
|
|
432
|
-
configDir: harness.configDir,
|
|
433
|
-
readManifest: fakeReadManifest,
|
|
434
|
-
restartService: async (name) => {
|
|
435
|
-
restarts.push(name);
|
|
436
|
-
return 0;
|
|
437
|
-
},
|
|
438
|
-
});
|
|
439
|
-
expect(res.status).toBe(302);
|
|
440
|
-
const location = res.headers.get("location") ?? "";
|
|
441
|
-
expect(location).toContain("/admin/config?");
|
|
442
|
-
expect(location).toContain("_status=saved");
|
|
443
|
-
expect(location).toContain("_module=vault");
|
|
444
|
-
expect(location).toContain("#module-vault");
|
|
445
|
-
expect(restarts).toEqual(["vault"]);
|
|
446
|
-
const written = JSON.parse(
|
|
447
|
-
readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
|
|
448
|
-
);
|
|
449
|
-
expect(written).toEqual({
|
|
450
|
-
transcribe_provider: "deepgram",
|
|
451
|
-
max_tags_per_note: 25,
|
|
452
|
-
public: false,
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
test("flashes saved-restart-failed when the restart returns non-zero", async () => {
|
|
457
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
458
|
-
const { body, headers } = postBody({
|
|
459
|
-
transcribe_provider: "openai",
|
|
460
|
-
max_tags_per_note: "5",
|
|
461
|
-
});
|
|
462
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
463
|
-
method: "POST",
|
|
464
|
-
headers: { ...headers, cookie },
|
|
465
|
-
body,
|
|
466
|
-
});
|
|
467
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
468
|
-
loadServicesManifest: vaultServices,
|
|
469
|
-
configDir: harness.configDir,
|
|
470
|
-
readManifest: fakeReadManifest,
|
|
471
|
-
restartService: async () => 1,
|
|
472
|
-
});
|
|
473
|
-
expect(res.status).toBe(302);
|
|
474
|
-
const location = res.headers.get("location") ?? "";
|
|
475
|
-
expect(location).toContain("_status=saved-restart-failed");
|
|
476
|
-
// Config was still written before the restart attempt.
|
|
477
|
-
expect(existsSync(join(harness.configDir, "vault", "config.json"))).toBe(true);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
test("flashes saved-restart-failed with err detail when restart throws", async () => {
|
|
481
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
482
|
-
const { body, headers } = postBody({
|
|
483
|
-
transcribe_provider: "openai",
|
|
484
|
-
max_tags_per_note: "5",
|
|
485
|
-
});
|
|
486
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
487
|
-
method: "POST",
|
|
488
|
-
headers: { ...headers, cookie },
|
|
489
|
-
body,
|
|
490
|
-
});
|
|
491
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
492
|
-
loadServicesManifest: vaultServices,
|
|
493
|
-
configDir: harness.configDir,
|
|
494
|
-
readManifest: fakeReadManifest,
|
|
495
|
-
restartService: async () => {
|
|
496
|
-
throw new Error("launchctl unavailable");
|
|
497
|
-
},
|
|
498
|
-
});
|
|
499
|
-
expect(res.status).toBe(302);
|
|
500
|
-
const location = res.headers.get("location") ?? "";
|
|
501
|
-
expect(location).toContain("_status=saved-restart-failed");
|
|
502
|
-
const errParam = new URL(location, "http://hub.test").searchParams.get("_err") ?? "";
|
|
503
|
-
expect(errParam).toContain("launchctl unavailable");
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
test("checkbox-on translates to `public: true` in the written config", async () => {
|
|
507
|
-
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
508
|
-
const { body, headers } = postBody({
|
|
509
|
-
transcribe_provider: "openai",
|
|
510
|
-
max_tags_per_note: "5",
|
|
511
|
-
public: "true",
|
|
512
|
-
});
|
|
513
|
-
const req = new Request("http://hub.test/admin/config/vault", {
|
|
514
|
-
method: "POST",
|
|
515
|
-
headers: { ...headers, cookie },
|
|
516
|
-
body,
|
|
517
|
-
});
|
|
518
|
-
const res = await handleAdminConfigPost(harness.db, req, "vault", {
|
|
519
|
-
loadServicesManifest: vaultServices,
|
|
520
|
-
configDir: harness.configDir,
|
|
521
|
-
readManifest: fakeReadManifest,
|
|
522
|
-
restartService: async () => 0,
|
|
523
|
-
});
|
|
524
|
-
expect(res.status).toBe(302);
|
|
525
|
-
const written = JSON.parse(
|
|
526
|
-
readFileSync(join(harness.configDir, "vault", "config.json"), "utf8"),
|
|
527
|
-
);
|
|
528
|
-
expect(written.public).toBe(true);
|
|
529
|
-
});
|
|
530
|
-
});
|
|
@@ -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
|
});
|