@openparachute/hub 0.5.10-rc.9 → 0.5.10
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-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +74 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +216 -3
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +15 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +134 -16
- package/src/users.ts +210 -3
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `/api/users*` (multi-user Phase 1, PR 2). Covers:
|
|
3
|
+
*
|
|
4
|
+
* - Auth boundary: every endpoint requires a bearer carrying
|
|
5
|
+
* `parachute:host:admin`.
|
|
6
|
+
* - GET happy path (list, no hash leakage).
|
|
7
|
+
* - POST happy path + every validator branch (bad username
|
|
8
|
+
* format/length/reserved, password too short, password > 256 chars
|
|
9
|
+
* returns 413 BEFORE argon2id touches it, conflict 409 case-
|
|
10
|
+
* insensitive, assigned_vault missing-from-services.json returns
|
|
11
|
+
* 400 `assigned_vault_not_found`).
|
|
12
|
+
* - DELETE happy path with token revocation; first-admin-undeletable
|
|
13
|
+
* returns 403; 404 on unknown id.
|
|
14
|
+
* - GET /api/users/vaults returns the same name set the OAuth issuer
|
|
15
|
+
* would resolve against.
|
|
16
|
+
* - 405 on wrong methods.
|
|
17
|
+
*/
|
|
18
|
+
import type { Database } from "bun:sqlite";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
handleCreateUser,
|
|
25
|
+
handleDeleteUser,
|
|
26
|
+
handleListUsers,
|
|
27
|
+
handleListVaults,
|
|
28
|
+
} from "../api-users.ts";
|
|
29
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
30
|
+
import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
31
|
+
import { createUser } from "../users.ts";
|
|
32
|
+
|
|
33
|
+
const ISSUER = "https://hub.test";
|
|
34
|
+
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
35
|
+
|
|
36
|
+
interface Harness {
|
|
37
|
+
db: Database;
|
|
38
|
+
manifestPath: string;
|
|
39
|
+
cleanup: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeHarness(servicesJson?: string): Harness {
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-users-"));
|
|
44
|
+
const db = openHubDb(hubDbPath(dir));
|
|
45
|
+
const manifestPath = join(dir, "services.json");
|
|
46
|
+
// Default manifest — empty services list; tests that want a vault can
|
|
47
|
+
// override by passing servicesJson.
|
|
48
|
+
writeFileSync(manifestPath, servicesJson ?? JSON.stringify({ services: [] }));
|
|
49
|
+
return {
|
|
50
|
+
db,
|
|
51
|
+
manifestPath,
|
|
52
|
+
cleanup: () => {
|
|
53
|
+
db.close();
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function manifestWithVaults(...names: string[]): string {
|
|
60
|
+
// Single-entry shape: one `parachute-vault` service with N paths.
|
|
61
|
+
const paths = names.map((n) => `/vault/${n}`);
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
services: [
|
|
64
|
+
{
|
|
65
|
+
name: "parachute-vault",
|
|
66
|
+
port: 4101,
|
|
67
|
+
paths,
|
|
68
|
+
health: "/health",
|
|
69
|
+
version: "0.0.0-test",
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let harness: Harness;
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
harness = makeHarness();
|
|
78
|
+
});
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
harness.cleanup();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
interface MintedBearer {
|
|
84
|
+
bearer: string;
|
|
85
|
+
jti: string;
|
|
86
|
+
userId: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function makeAdminBearer(
|
|
90
|
+
scopes = [HOST_ADMIN_SCOPE],
|
|
91
|
+
username = "operator",
|
|
92
|
+
): Promise<MintedBearer> {
|
|
93
|
+
const user = await createUser(harness.db, username, "any-password", {
|
|
94
|
+
allowMulti: true,
|
|
95
|
+
passwordChanged: true,
|
|
96
|
+
});
|
|
97
|
+
const minted = await signAccessToken(harness.db, {
|
|
98
|
+
sub: user.id,
|
|
99
|
+
scopes,
|
|
100
|
+
audience: "hub",
|
|
101
|
+
clientId: "parachute-hub-spa",
|
|
102
|
+
issuer: ISSUER,
|
|
103
|
+
ttlSeconds: 600,
|
|
104
|
+
});
|
|
105
|
+
return { bearer: minted.token, jti: minted.jti, userId: user.id };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function req(path: string, init: RequestInit = {}): Request {
|
|
109
|
+
return new Request(`${ISSUER}${path}`, init);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
|
|
113
|
+
const headers = new Headers(init.headers ?? {});
|
|
114
|
+
headers.set("authorization", `Bearer ${bearer}`);
|
|
115
|
+
return new Request(`${ISSUER}${path}`, { ...init, headers });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function deps(): {
|
|
119
|
+
db: Database;
|
|
120
|
+
issuer: string;
|
|
121
|
+
manifestPath: string;
|
|
122
|
+
} {
|
|
123
|
+
return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// GET /api/users — list users
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
describe("handleListUsers", () => {
|
|
131
|
+
test("401 with no Authorization header", async () => {
|
|
132
|
+
const res = await handleListUsers(req("/api/users"), deps());
|
|
133
|
+
expect(res.status).toBe(401);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
137
|
+
const { bearer } = await makeAdminBearer(["other:scope"]);
|
|
138
|
+
const res = await handleListUsers(withBearer("/api/users", bearer), deps());
|
|
139
|
+
expect(res.status).toBe(403);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("405 on POST", async () => {
|
|
143
|
+
const { bearer } = await makeAdminBearer();
|
|
144
|
+
const res = await handleListUsers(withBearer("/api/users", bearer, { method: "POST" }), deps());
|
|
145
|
+
// The handler itself is GET-only; the hub-server dispatcher routes
|
|
146
|
+
// POSTs to handleCreateUser. We assert the handler's own contract.
|
|
147
|
+
expect(res.status).toBe(405);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("lists users in created_at ASC order, omitting password_hash", async () => {
|
|
151
|
+
const { bearer } = await makeAdminBearer();
|
|
152
|
+
await createUser(harness.db, "alice", "alice-strong-password", {
|
|
153
|
+
allowMulti: true,
|
|
154
|
+
});
|
|
155
|
+
const res = await handleListUsers(withBearer("/api/users", bearer), deps());
|
|
156
|
+
expect(res.status).toBe(200);
|
|
157
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
158
|
+
const body = (await res.json()) as {
|
|
159
|
+
users: Array<Record<string, unknown>>;
|
|
160
|
+
};
|
|
161
|
+
expect(body.users.length).toBe(2);
|
|
162
|
+
// First admin (operator) created first → first row.
|
|
163
|
+
expect(body.users[0]?.username).toBe("operator");
|
|
164
|
+
expect(body.users[1]?.username).toBe("alice");
|
|
165
|
+
// Hash never leaks.
|
|
166
|
+
for (const u of body.users) {
|
|
167
|
+
expect(u).not.toHaveProperty("password_hash");
|
|
168
|
+
expect(u).not.toHaveProperty("passwordHash");
|
|
169
|
+
// Snake-case wire shape.
|
|
170
|
+
expect(u).toHaveProperty("id");
|
|
171
|
+
expect(u).toHaveProperty("username");
|
|
172
|
+
expect(u).toHaveProperty("password_changed");
|
|
173
|
+
expect(u).toHaveProperty("assigned_vault");
|
|
174
|
+
expect(u).toHaveProperty("created_at");
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// POST /api/users — create user
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe("handleCreateUser", () => {
|
|
184
|
+
async function post(
|
|
185
|
+
bearer: string,
|
|
186
|
+
body: Record<string, unknown> | string,
|
|
187
|
+
headers: Record<string, string> = {},
|
|
188
|
+
): Promise<Response> {
|
|
189
|
+
const init: RequestInit = {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: {
|
|
192
|
+
"content-type": "application/json",
|
|
193
|
+
authorization: `Bearer ${bearer}`,
|
|
194
|
+
...headers,
|
|
195
|
+
},
|
|
196
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
197
|
+
};
|
|
198
|
+
return await handleCreateUser(req("/api/users", init), deps());
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test("401 with no Authorization header", async () => {
|
|
202
|
+
const res = await handleCreateUser(req("/api/users", { method: "POST", body: "{}" }), deps());
|
|
203
|
+
expect(res.status).toBe(401);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
207
|
+
const { bearer } = await makeAdminBearer(["other:scope"]);
|
|
208
|
+
const res = await post(bearer, { username: "x", password: "y" });
|
|
209
|
+
expect(res.status).toBe(403);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("happy path returns 201 + user with password_changed=false, no hash", async () => {
|
|
213
|
+
const { bearer } = await makeAdminBearer();
|
|
214
|
+
const res = await post(bearer, {
|
|
215
|
+
username: "alice",
|
|
216
|
+
password: "alice-strong-passphrase",
|
|
217
|
+
assignedVault: null,
|
|
218
|
+
});
|
|
219
|
+
expect(res.status).toBe(201);
|
|
220
|
+
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
221
|
+
expect(body.user.username).toBe("alice");
|
|
222
|
+
expect(body.user.password_changed).toBe(false);
|
|
223
|
+
expect(body.user.assigned_vault).toBeNull();
|
|
224
|
+
expect(body.user).not.toHaveProperty("password_hash");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("rejects non-JSON content-type", async () => {
|
|
228
|
+
const { bearer } = await makeAdminBearer();
|
|
229
|
+
const res = await post(bearer, "not-json", { "content-type": "text/plain" });
|
|
230
|
+
expect(res.status).toBe(400);
|
|
231
|
+
const body = (await res.json()) as { error: string };
|
|
232
|
+
expect(body.error).toBe("invalid_request");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("rejects malformed JSON body", async () => {
|
|
236
|
+
const { bearer } = await makeAdminBearer();
|
|
237
|
+
const res = await post(bearer, "{not valid");
|
|
238
|
+
expect(res.status).toBe(400);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("400 invalid_username when username too short (length reason)", async () => {
|
|
242
|
+
const { bearer } = await makeAdminBearer();
|
|
243
|
+
const res = await post(bearer, {
|
|
244
|
+
username: "a",
|
|
245
|
+
password: "strong-passphrase-123",
|
|
246
|
+
});
|
|
247
|
+
expect(res.status).toBe(400);
|
|
248
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
249
|
+
expect(body.error).toBe("invalid_username");
|
|
250
|
+
expect(body.error_description).toMatch(/2-32/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("400 invalid_username when username has bad characters (format reason)", async () => {
|
|
254
|
+
const { bearer } = await makeAdminBearer();
|
|
255
|
+
const res = await post(bearer, {
|
|
256
|
+
username: "Alice",
|
|
257
|
+
password: "strong-passphrase-123",
|
|
258
|
+
});
|
|
259
|
+
expect(res.status).toBe(400);
|
|
260
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
261
|
+
expect(body.error).toBe("invalid_username");
|
|
262
|
+
expect(body.error_description).toMatch(/lowercase/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("400 invalid_username when username is reserved", async () => {
|
|
266
|
+
const { bearer } = await makeAdminBearer();
|
|
267
|
+
const res = await post(bearer, {
|
|
268
|
+
username: "admin",
|
|
269
|
+
password: "strong-passphrase-123",
|
|
270
|
+
});
|
|
271
|
+
expect(res.status).toBe(400);
|
|
272
|
+
const body = (await res.json()) as { error: string };
|
|
273
|
+
expect(body.error).toBe("invalid_username");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("400 invalid_password when password is too short (< 12 chars)", async () => {
|
|
277
|
+
const { bearer } = await makeAdminBearer();
|
|
278
|
+
const res = await post(bearer, {
|
|
279
|
+
username: "alice",
|
|
280
|
+
password: "short",
|
|
281
|
+
});
|
|
282
|
+
expect(res.status).toBe(400);
|
|
283
|
+
const body = (await res.json()) as { error: string };
|
|
284
|
+
expect(body.error).toBe("invalid_password");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("413 password_too_long when password > 256 chars (before argon2id touches it)", async () => {
|
|
288
|
+
const { bearer } = await makeAdminBearer();
|
|
289
|
+
const huge = "a".repeat(300);
|
|
290
|
+
const t0 = Date.now();
|
|
291
|
+
const res = await post(bearer, { username: "alice", password: huge });
|
|
292
|
+
const elapsed = Date.now() - t0;
|
|
293
|
+
expect(res.status).toBe(413);
|
|
294
|
+
const body = (await res.json()) as { error: string };
|
|
295
|
+
expect(body.error).toBe("password_too_long");
|
|
296
|
+
// Sanity check the cap fires before argon2id — a 300-char argon2id
|
|
297
|
+
// hash is well into the hundreds of ms; the cap-and-reject path
|
|
298
|
+
// should complete in <50ms on any sane runner. Floor of 200ms here
|
|
299
|
+
// keeps the check noise-tolerant.
|
|
300
|
+
expect(elapsed).toBeLessThan(200);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("409 username_taken on exact-duplicate POST", async () => {
|
|
304
|
+
const { bearer } = await makeAdminBearer();
|
|
305
|
+
await post(bearer, {
|
|
306
|
+
username: "alice",
|
|
307
|
+
password: "alice-strong-passphrase",
|
|
308
|
+
});
|
|
309
|
+
const res = await post(bearer, {
|
|
310
|
+
username: "alice",
|
|
311
|
+
password: "alice-strong-passphrase",
|
|
312
|
+
});
|
|
313
|
+
expect(res.status).toBe(409);
|
|
314
|
+
const body = (await res.json()) as { error: string };
|
|
315
|
+
expect(body.error).toBe("username_taken");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("409 username_taken shadows a hand-inserted mixed-case legacy row (CI path)", async () => {
|
|
319
|
+
// Insert a mixed-case row directly — bypasses the validator's
|
|
320
|
+
// lowercase-only gate so we can prove the CI shadowing check
|
|
321
|
+
// (`getUserByUsernameCI` / `COLLATE NOCASE`) actually fires. The
|
|
322
|
+
// exact-duplicate test above can't exercise this path because the
|
|
323
|
+
// validator rejects "Alice" with `invalid_username` (format) long
|
|
324
|
+
// before the CI lookup runs.
|
|
325
|
+
const stamp = "2026-05-20T00:00:00.000Z";
|
|
326
|
+
harness.db
|
|
327
|
+
.prepare(
|
|
328
|
+
"INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed, assigned_vault) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
329
|
+
)
|
|
330
|
+
.run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1, null);
|
|
331
|
+
const { bearer } = await makeAdminBearer();
|
|
332
|
+
const res = await post(bearer, {
|
|
333
|
+
username: "alice",
|
|
334
|
+
password: "alice-strong-passphrase",
|
|
335
|
+
});
|
|
336
|
+
expect(res.status).toBe(409);
|
|
337
|
+
const body = (await res.json()) as { error: string };
|
|
338
|
+
expect(body.error).toBe("username_taken");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("400 assigned_vault_not_found when vault is not in services.json", async () => {
|
|
342
|
+
const { bearer } = await makeAdminBearer();
|
|
343
|
+
const res = await post(bearer, {
|
|
344
|
+
username: "alice",
|
|
345
|
+
password: "alice-strong-passphrase",
|
|
346
|
+
assignedVault: "ghost-vault",
|
|
347
|
+
});
|
|
348
|
+
expect(res.status).toBe(400);
|
|
349
|
+
const body = (await res.json()) as { error: string };
|
|
350
|
+
expect(body.error).toBe("assigned_vault_not_found");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("happy path with assigned_vault that exists in services.json", async () => {
|
|
354
|
+
harness.cleanup();
|
|
355
|
+
harness = makeHarness(manifestWithVaults("home"));
|
|
356
|
+
const { bearer } = await makeAdminBearer();
|
|
357
|
+
const res = await post(bearer, {
|
|
358
|
+
username: "alice",
|
|
359
|
+
password: "alice-strong-passphrase",
|
|
360
|
+
assignedVault: "home",
|
|
361
|
+
});
|
|
362
|
+
expect(res.status).toBe(201);
|
|
363
|
+
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
364
|
+
expect(body.user.assigned_vault).toBe("home");
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// DELETE /api/users/:id — hard-delete user
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
describe("handleDeleteUser", () => {
|
|
373
|
+
test("401 with no Authorization header", async () => {
|
|
374
|
+
const res = await handleDeleteUser(
|
|
375
|
+
req("/api/users/some-id", { method: "DELETE" }),
|
|
376
|
+
"some-id",
|
|
377
|
+
deps(),
|
|
378
|
+
);
|
|
379
|
+
expect(res.status).toBe(401);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
383
|
+
const { bearer } = await makeAdminBearer(["other:scope"]);
|
|
384
|
+
const res = await handleDeleteUser(
|
|
385
|
+
withBearer("/api/users/some-id", bearer, { method: "DELETE" }),
|
|
386
|
+
"some-id",
|
|
387
|
+
deps(),
|
|
388
|
+
);
|
|
389
|
+
expect(res.status).toBe(403);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("405 on GET", async () => {
|
|
393
|
+
const { bearer } = await makeAdminBearer();
|
|
394
|
+
const res = await handleDeleteUser(
|
|
395
|
+
withBearer("/api/users/some-id", bearer, { method: "GET" }),
|
|
396
|
+
"some-id",
|
|
397
|
+
deps(),
|
|
398
|
+
);
|
|
399
|
+
expect(res.status).toBe(405);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("404 when user does not exist", async () => {
|
|
403
|
+
const { bearer } = await makeAdminBearer();
|
|
404
|
+
const res = await handleDeleteUser(
|
|
405
|
+
withBearer("/api/users/no-such-id", bearer, { method: "DELETE" }),
|
|
406
|
+
"no-such-id",
|
|
407
|
+
deps(),
|
|
408
|
+
);
|
|
409
|
+
expect(res.status).toBe(404);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("403 first_admin_undeletable when deleting the earliest user", async () => {
|
|
413
|
+
const { bearer, userId } = await makeAdminBearer();
|
|
414
|
+
const res = await handleDeleteUser(
|
|
415
|
+
withBearer(`/api/users/${userId}`, bearer, { method: "DELETE" }),
|
|
416
|
+
userId,
|
|
417
|
+
deps(),
|
|
418
|
+
);
|
|
419
|
+
expect(res.status).toBe(403);
|
|
420
|
+
const body = (await res.json()) as { error: string };
|
|
421
|
+
expect(body.error).toBe("first_admin_undeletable");
|
|
422
|
+
// The user row still exists (delete refused).
|
|
423
|
+
const stillThere = await handleListUsers(withBearer("/api/users", bearer), deps());
|
|
424
|
+
const list = (await stillThere.json()) as { users: Array<{ id: string }> };
|
|
425
|
+
expect(list.users.map((u) => u.id)).toContain(userId);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("204 deletes a non-first user and revokes their tokens", async () => {
|
|
429
|
+
const { bearer } = await makeAdminBearer();
|
|
430
|
+
// Create a second user (non-first) + mint a token on their behalf.
|
|
431
|
+
const second = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
432
|
+
allowMulti: true,
|
|
433
|
+
passwordChanged: true,
|
|
434
|
+
});
|
|
435
|
+
const minted = await signAccessToken(harness.db, {
|
|
436
|
+
sub: second.id,
|
|
437
|
+
scopes: ["vault:home:read"],
|
|
438
|
+
audience: "vault",
|
|
439
|
+
clientId: "notes-client",
|
|
440
|
+
issuer: ISSUER,
|
|
441
|
+
ttlSeconds: 600,
|
|
442
|
+
});
|
|
443
|
+
// `signAccessToken` mints the JWT but doesn't write a `tokens` row.
|
|
444
|
+
// Production paths that mint a registry-row token call
|
|
445
|
+
// `recordTokenMint` immediately afterwards; mirror that here so the
|
|
446
|
+
// delete-user revocation has something to flip + null.
|
|
447
|
+
recordTokenMint(harness.db, {
|
|
448
|
+
jti: minted.jti,
|
|
449
|
+
createdVia: "operator_mint",
|
|
450
|
+
subject: second.username,
|
|
451
|
+
userId: second.id,
|
|
452
|
+
clientId: "notes-client",
|
|
453
|
+
scopes: ["vault:home:read"],
|
|
454
|
+
expiresAt: minted.expiresAt,
|
|
455
|
+
});
|
|
456
|
+
expect(findTokenRowByJti(harness.db, minted.jti)?.revokedAt).toBeNull();
|
|
457
|
+
|
|
458
|
+
const res = await handleDeleteUser(
|
|
459
|
+
withBearer(`/api/users/${second.id}`, bearer, { method: "DELETE" }),
|
|
460
|
+
second.id,
|
|
461
|
+
deps(),
|
|
462
|
+
);
|
|
463
|
+
expect(res.status).toBe(204);
|
|
464
|
+
|
|
465
|
+
// User row is gone.
|
|
466
|
+
const listRes = await handleListUsers(withBearer("/api/users", bearer), deps());
|
|
467
|
+
const list = (await listRes.json()) as { users: Array<{ id: string }> };
|
|
468
|
+
expect(list.users.map((u) => u.id)).not.toContain(second.id);
|
|
469
|
+
|
|
470
|
+
// Token row stays for audit but is now revoked + user_id NULLed +
|
|
471
|
+
// subject backfilled with the username.
|
|
472
|
+
const row = findTokenRowByJti(harness.db, minted.jti);
|
|
473
|
+
expect(row).not.toBeNull();
|
|
474
|
+
expect(row?.revokedAt).not.toBeNull();
|
|
475
|
+
expect(row?.userId).toBeNull();
|
|
476
|
+
expect(row?.subject).toBe("alice");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// GET /api/users/vaults — vault-name list for the assigned-vault dropdown
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
describe("handleListVaults", () => {
|
|
485
|
+
test("401 with no Authorization header", async () => {
|
|
486
|
+
const res = await handleListVaults(req("/api/users/vaults"), deps());
|
|
487
|
+
expect(res.status).toBe(401);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
491
|
+
const { bearer } = await makeAdminBearer(["other:scope"]);
|
|
492
|
+
const res = await handleListVaults(withBearer("/api/users/vaults", bearer), deps());
|
|
493
|
+
expect(res.status).toBe(403);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("405 on POST", async () => {
|
|
497
|
+
const { bearer } = await makeAdminBearer();
|
|
498
|
+
const res = await handleListVaults(
|
|
499
|
+
withBearer("/api/users/vaults", bearer, { method: "POST" }),
|
|
500
|
+
deps(),
|
|
501
|
+
);
|
|
502
|
+
expect(res.status).toBe(405);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("returns empty list when no vaults are registered", async () => {
|
|
506
|
+
const { bearer } = await makeAdminBearer();
|
|
507
|
+
const res = await handleListVaults(withBearer("/api/users/vaults", bearer), deps());
|
|
508
|
+
expect(res.status).toBe(200);
|
|
509
|
+
const body = (await res.json()) as { vaults: string[] };
|
|
510
|
+
expect(body.vaults).toEqual([]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("returns sorted vault names from services.json", async () => {
|
|
514
|
+
harness.cleanup();
|
|
515
|
+
harness = makeHarness(manifestWithVaults("home", "work", "scratch"));
|
|
516
|
+
const { bearer } = await makeAdminBearer();
|
|
517
|
+
const res = await handleListVaults(withBearer("/api/users/vaults", bearer), deps());
|
|
518
|
+
expect(res.status).toBe(200);
|
|
519
|
+
const body = (await res.json()) as { vaults: string[] };
|
|
520
|
+
expect(body.vaults).toEqual(["home", "scratch", "work"]);
|
|
521
|
+
});
|
|
522
|
+
});
|