@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,530 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
handleAdminConfigGet,
|
|
8
|
+
handleAdminConfigPost,
|
|
9
|
+
handleAdminLoginGet,
|
|
10
|
+
handleAdminLoginPost,
|
|
11
|
+
handleAdminLogoutPost,
|
|
12
|
+
} from "../admin-handlers.ts";
|
|
13
|
+
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
14
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
15
|
+
import type { ConfigSchema, ModuleManifest } from "../module-manifest.ts";
|
|
16
|
+
import type { ServicesManifest } from "../services-manifest.ts";
|
|
17
|
+
import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
|
|
18
|
+
import { createUser } from "../users.ts";
|
|
19
|
+
|
|
20
|
+
const TEST_CSRF = "csrf-handlers-test-token";
|
|
21
|
+
const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
|
|
22
|
+
|
|
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
|
+
interface Harness {
|
|
65
|
+
db: Database;
|
|
66
|
+
configDir: string;
|
|
67
|
+
cleanup: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeHarness(): Harness {
|
|
71
|
+
const configDir = mkdtempSync(join(tmpdir(), "phub-admin-handlers-"));
|
|
72
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
73
|
+
return {
|
|
74
|
+
db,
|
|
75
|
+
configDir,
|
|
76
|
+
cleanup: () => {
|
|
77
|
+
db.close();
|
|
78
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function cookieForUser(db: Database, username: string, password: string): Promise<string> {
|
|
84
|
+
const user = await createUser(db, username, password);
|
|
85
|
+
const session = createSession(db, { userId: user.id });
|
|
86
|
+
return `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formBody(values: Record<string, string>): {
|
|
90
|
+
body: string;
|
|
91
|
+
headers: Record<string, string>;
|
|
92
|
+
} {
|
|
93
|
+
const params = new URLSearchParams();
|
|
94
|
+
for (const [k, v] of Object.entries(values)) params.append(k, v);
|
|
95
|
+
return {
|
|
96
|
+
body: params.toString(),
|
|
97
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
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
|
+
let harness: Harness;
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
harness = makeHarness();
|
|
109
|
+
});
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
harness.cleanup();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("handleAdminLoginGet", () => {
|
|
115
|
+
test("renders login form and mints a CSRF cookie when none is present", () => {
|
|
116
|
+
const req = new Request("http://hub.test/admin/login");
|
|
117
|
+
const res = handleAdminLoginGet(harness.db, req);
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(res.headers.get("set-cookie") ?? "").toContain(CSRF_COOKIE_NAME);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("echoes the next= query param into the form", async () => {
|
|
123
|
+
const req = new Request("http://hub.test/admin/login?next=/admin/config");
|
|
124
|
+
const res = handleAdminLoginGet(harness.db, req);
|
|
125
|
+
const html = await res.text();
|
|
126
|
+
expect(html).toContain('value="/admin/config"');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("rewrites unsafe next= to /admin/config", async () => {
|
|
130
|
+
const req = new Request("http://hub.test/admin/login?next=https%3A%2F%2Fevil.example%2Fpwn");
|
|
131
|
+
const res = handleAdminLoginGet(harness.db, req);
|
|
132
|
+
const html = await res.text();
|
|
133
|
+
expect(html).toContain('value="/admin/config"');
|
|
134
|
+
expect(html).not.toContain("evil.example");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("hidden __csrf input value matches the freshly-minted cookie value (#113)", async () => {
|
|
138
|
+
const req = new Request("http://hub.test/admin/login");
|
|
139
|
+
const res = handleAdminLoginGet(harness.db, req);
|
|
140
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
141
|
+
const cookieMatch = setCookie.match(new RegExp(`${CSRF_COOKIE_NAME}=([A-Za-z0-9_-]+)`));
|
|
142
|
+
expect(cookieMatch).not.toBeNull();
|
|
143
|
+
const cookieToken = cookieMatch?.[1] ?? "";
|
|
144
|
+
expect(cookieToken.length).toBeGreaterThan(0);
|
|
145
|
+
const html = await res.text();
|
|
146
|
+
const formMatch = html.match(new RegExp(`name="${CSRF_FIELD_NAME}" value="([^"]+)"`));
|
|
147
|
+
expect(formMatch).not.toBeNull();
|
|
148
|
+
expect(formMatch?.[1]).toBe(cookieToken);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("handleAdminLoginPost", () => {
|
|
153
|
+
test("rejects when CSRF token doesn't match the cookie", async () => {
|
|
154
|
+
await createUser(harness.db, "admin", "pw");
|
|
155
|
+
const { body, headers } = formBody({
|
|
156
|
+
[CSRF_FIELD_NAME]: "wrong",
|
|
157
|
+
username: "admin",
|
|
158
|
+
password: "pw",
|
|
159
|
+
next: "/admin/config",
|
|
160
|
+
});
|
|
161
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
164
|
+
body,
|
|
165
|
+
});
|
|
166
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
167
|
+
expect(res.status).toBe(400);
|
|
168
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("rejects bad credentials with 401 and re-renders login", async () => {
|
|
172
|
+
await createUser(harness.db, "admin", "correct-pw");
|
|
173
|
+
const { body, headers } = formBody({
|
|
174
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
175
|
+
username: "admin",
|
|
176
|
+
password: "wrong",
|
|
177
|
+
next: "/admin/config",
|
|
178
|
+
});
|
|
179
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
182
|
+
body,
|
|
183
|
+
});
|
|
184
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
185
|
+
expect(res.status).toBe(401);
|
|
186
|
+
expect(await res.text()).toContain("Invalid credentials");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("redirects to next= and sets session cookie on success", async () => {
|
|
190
|
+
await createUser(harness.db, "admin", "pw");
|
|
191
|
+
const { body, headers } = formBody({
|
|
192
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
193
|
+
username: "admin",
|
|
194
|
+
password: "pw",
|
|
195
|
+
next: "/admin/config",
|
|
196
|
+
});
|
|
197
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
200
|
+
body,
|
|
201
|
+
});
|
|
202
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
203
|
+
expect(res.status).toBe(302);
|
|
204
|
+
expect(res.headers.get("location")).toBe("/admin/config");
|
|
205
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("ignores an absolute-URL next= from the form", async () => {
|
|
209
|
+
await createUser(harness.db, "admin", "pw");
|
|
210
|
+
const { body, headers } = formBody({
|
|
211
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
212
|
+
username: "admin",
|
|
213
|
+
password: "pw",
|
|
214
|
+
next: "https://evil.example/pwn",
|
|
215
|
+
});
|
|
216
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
219
|
+
body,
|
|
220
|
+
});
|
|
221
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
222
|
+
expect(res.status).toBe(302);
|
|
223
|
+
expect(res.headers.get("location")).toBe("/admin/config");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("handleAdminLogoutPost (#113)", () => {
|
|
228
|
+
test("rejects when CSRF token doesn't match the cookie", async () => {
|
|
229
|
+
const cookie = await cookieForUser(harness.db, "admin", "pw");
|
|
230
|
+
const { body, headers } = formBody({ [CSRF_FIELD_NAME]: "wrong" });
|
|
231
|
+
const req = new Request("http://hub.test/admin/logout", {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { ...headers, cookie },
|
|
234
|
+
body,
|
|
235
|
+
});
|
|
236
|
+
const res = await handleAdminLogoutPost(harness.db, req);
|
|
237
|
+
expect(res.status).toBe(400);
|
|
238
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("clears session cookie, deletes session row, and redirects to /admin/login", async () => {
|
|
242
|
+
const user = await createUser(harness.db, "admin", "pw");
|
|
243
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
244
|
+
const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(
|
|
245
|
+
session.id,
|
|
246
|
+
Math.floor(SESSION_TTL_MS / 1000),
|
|
247
|
+
)}`;
|
|
248
|
+
const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
|
|
249
|
+
const req = new Request("http://hub.test/admin/logout", {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { ...headers, cookie },
|
|
252
|
+
body,
|
|
253
|
+
});
|
|
254
|
+
const res = await handleAdminLogoutPost(harness.db, req);
|
|
255
|
+
expect(res.status).toBe(302);
|
|
256
|
+
expect(res.headers.get("location")).toBe("/admin/login");
|
|
257
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
258
|
+
expect(setCookie).toContain("parachute_hub_session=;");
|
|
259
|
+
expect(setCookie).toContain("Max-Age=0");
|
|
260
|
+
expect(findSession(harness.db, session.id)).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("idempotent — clears cookie even with no active session", async () => {
|
|
264
|
+
const { body, headers } = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF });
|
|
265
|
+
const req = new Request("http://hub.test/admin/logout", {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
268
|
+
body,
|
|
269
|
+
});
|
|
270
|
+
const res = await handleAdminLogoutPost(harness.db, req);
|
|
271
|
+
expect(res.status).toBe(302);
|
|
272
|
+
expect(res.headers.get("location")).toBe("/admin/login");
|
|
273
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
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
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the SPA's session→bearer mint endpoint. Covers:
|
|
3
|
+
* - 401 when no admin session cookie is present.
|
|
4
|
+
* - 401 when the cookie names a deleted/expired session.
|
|
5
|
+
* - 200 + JWT carrying parachute:host:admin when the session is valid.
|
|
6
|
+
* - Token validates against the hub's own keys and the configured issuer.
|
|
7
|
+
* - Method-not-allowed on POST.
|
|
8
|
+
*/
|
|
9
|
+
import type { Database } from "bun:sqlite";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { HOST_ADMIN_TOKEN_TTL_SECONDS, handleHostAdminToken } from "../admin-host-admin-token.ts";
|
|
15
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
17
|
+
import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
|
|
18
|
+
import { createUser } from "../users.ts";
|
|
19
|
+
|
|
20
|
+
const ISSUER = "https://hub.test";
|
|
21
|
+
|
|
22
|
+
interface Harness {
|
|
23
|
+
db: Database;
|
|
24
|
+
cleanup: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeHarness(): Harness {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-host-admin-token-"));
|
|
29
|
+
const db = openHubDb(hubDbPath(dir));
|
|
30
|
+
return {
|
|
31
|
+
db,
|
|
32
|
+
cleanup: () => {
|
|
33
|
+
db.close();
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let harness: Harness;
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
harness = makeHarness();
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
harness.cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function withSession(): Promise<{ cookie: string; userId: string }> {
|
|
48
|
+
const user = await createUser(harness.db, "operator", "hunter2");
|
|
49
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
50
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
51
|
+
return { cookie, userId: user.id };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("handleHostAdminToken", () => {
|
|
55
|
+
test("401 when no session cookie is present", async () => {
|
|
56
|
+
const req = new Request(`${ISSUER}/admin/host-admin-token`);
|
|
57
|
+
const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
|
|
58
|
+
expect(res.status).toBe(401);
|
|
59
|
+
const body = (await res.json()) as { error: string };
|
|
60
|
+
expect(body.error).toBe("unauthenticated");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("401 when the cookie names a deleted session", async () => {
|
|
64
|
+
const { cookie } = await withSession();
|
|
65
|
+
// Pluck the session id back out of the cookie + delete its row.
|
|
66
|
+
const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
|
|
67
|
+
deleteSession(harness.db, sid);
|
|
68
|
+
const req = new Request(`${ISSUER}/admin/host-admin-token`, {
|
|
69
|
+
headers: { cookie },
|
|
70
|
+
});
|
|
71
|
+
const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
|
|
72
|
+
expect(res.status).toBe(401);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("405 on POST", async () => {
|
|
76
|
+
const { cookie } = await withSession();
|
|
77
|
+
const req = new Request(`${ISSUER}/admin/host-admin-token`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { cookie },
|
|
80
|
+
});
|
|
81
|
+
const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
|
|
82
|
+
expect(res.status).toBe(405);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("200 mints a JWT carrying parachute:host:admin and the configured issuer", async () => {
|
|
86
|
+
const { cookie, userId } = await withSession();
|
|
87
|
+
const req = new Request(`${ISSUER}/admin/host-admin-token`, {
|
|
88
|
+
headers: { cookie },
|
|
89
|
+
});
|
|
90
|
+
const res = await handleHostAdminToken(req, { db: harness.db, issuer: ISSUER });
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
93
|
+
|
|
94
|
+
const body = (await res.json()) as {
|
|
95
|
+
token: string;
|
|
96
|
+
expires_at: string;
|
|
97
|
+
scopes: string[];
|
|
98
|
+
};
|
|
99
|
+
expect(body.scopes).toEqual(["parachute:host:admin"]);
|
|
100
|
+
expect(body.token.length).toBeGreaterThan(20);
|
|
101
|
+
|
|
102
|
+
// expires_at is roughly TTL_SECONDS in the future.
|
|
103
|
+
const expMs = new Date(body.expires_at).getTime();
|
|
104
|
+
const skew = expMs - Date.now();
|
|
105
|
+
expect(skew).toBeGreaterThan((HOST_ADMIN_TOKEN_TTL_SECONDS - 30) * 1000);
|
|
106
|
+
expect(skew).toBeLessThan((HOST_ADMIN_TOKEN_TTL_SECONDS + 30) * 1000);
|
|
107
|
+
|
|
108
|
+
// JWT verifies against the hub's own signing key + issuer.
|
|
109
|
+
const validated = await validateAccessToken(harness.db, body.token, ISSUER);
|
|
110
|
+
expect(validated.payload.sub).toBe(userId);
|
|
111
|
+
expect(validated.payload.iss).toBe(ISSUER);
|
|
112
|
+
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
113
|
+
expect(scopeClaim.split(/\s+/)).toContain("parachute:host:admin");
|
|
114
|
+
});
|
|
115
|
+
});
|