@openparachute/hub 0.5.10-rc.6 → 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 +139 -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-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -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 +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -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 +30 -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 +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -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 +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- 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
|
@@ -248,18 +248,92 @@ describe("Supervisor.stop", () => {
|
|
|
248
248
|
});
|
|
249
249
|
await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
|
|
250
250
|
|
|
251
|
-
|
|
251
|
+
// stop() now awaits proc.exited (with SIGKILL escalation on
|
|
252
|
+
// timeout) — kick it off, observe the SIGTERM landed, then
|
|
253
|
+
// resolve exited so the await completes.
|
|
254
|
+
const stopPromise = sup.stop("vault");
|
|
252
255
|
expect(proc.killed).toBe(true);
|
|
253
256
|
expect(proc.killSignal).toBe("SIGTERM");
|
|
254
257
|
|
|
255
258
|
proc.closeStreams();
|
|
256
259
|
proc.resolveExit(0);
|
|
257
|
-
await
|
|
260
|
+
await stopPromise;
|
|
258
261
|
|
|
259
262
|
// No second spawn — stop is an intentional exit.
|
|
260
263
|
expect(spawner.calls).toHaveLength(1);
|
|
261
264
|
expect(sup.get("vault")?.status).toBe("stopped");
|
|
262
265
|
});
|
|
266
|
+
|
|
267
|
+
test("escalates to SIGKILL when child ignores SIGTERM past killTimeoutMs", async () => {
|
|
268
|
+
// Child that refuses to exit on SIGTERM. The fake records every
|
|
269
|
+
// signal it receives; the supervisor should send SIGTERM,
|
|
270
|
+
// observe no exit, then send SIGKILL after the timeout.
|
|
271
|
+
const proc = makeFakeProc(101);
|
|
272
|
+
const signals: (NodeJS.Signals | number | undefined)[] = [];
|
|
273
|
+
proc.kill = (signal) => {
|
|
274
|
+
signals.push(signal);
|
|
275
|
+
// Only SIGKILL actually terminates this fake child — SIGTERM
|
|
276
|
+
// gets logged and ignored, simulating the wedged-module shape.
|
|
277
|
+
if (signal === "SIGKILL") proc.resolveExit(null);
|
|
278
|
+
};
|
|
279
|
+
const spawner = makeQueueSpawner();
|
|
280
|
+
spawner.enqueue(proc);
|
|
281
|
+
const outputs: string[] = [];
|
|
282
|
+
const sup = new Supervisor({
|
|
283
|
+
spawnFn: spawner.spawn,
|
|
284
|
+
restartDelayMs: 0,
|
|
285
|
+
sleep: () => Promise.resolve(),
|
|
286
|
+
killTimeoutMs: 5, // Short timeout so the test doesn't pause for 5s.
|
|
287
|
+
output: (line) => outputs.push(line),
|
|
288
|
+
});
|
|
289
|
+
await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
|
|
290
|
+
|
|
291
|
+
proc.closeStreams();
|
|
292
|
+
await sup.stop("vault");
|
|
293
|
+
|
|
294
|
+
// SIGTERM first, then SIGKILL after the timeout.
|
|
295
|
+
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
|
|
296
|
+
expect(outputs.some((l) => l.includes("escalating to SIGKILL"))).toBe(true);
|
|
297
|
+
expect(sup.get("vault")?.status).toBe("stopped");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("stop awaits child exit before returning (no SIGKILL needed)", async () => {
|
|
301
|
+
// Well-behaved child: exits ~10ms after SIGTERM. stop() should
|
|
302
|
+
// return only after the exit promise resolves, not immediately
|
|
303
|
+
// post-SIGTERM. This is the log-flush guarantee that motivated
|
|
304
|
+
// the await in the first place (hub#263).
|
|
305
|
+
const proc = makeFakeProc(101);
|
|
306
|
+
proc.kill = (signal) => {
|
|
307
|
+
signals.push(signal);
|
|
308
|
+
// Simulate the child taking a few ms to flush + exit.
|
|
309
|
+
setTimeout(() => proc.resolveExit(0), 5);
|
|
310
|
+
};
|
|
311
|
+
const signals: (NodeJS.Signals | number | undefined)[] = [];
|
|
312
|
+
const spawner = makeQueueSpawner();
|
|
313
|
+
spawner.enqueue(proc);
|
|
314
|
+
const sup = new Supervisor({
|
|
315
|
+
spawnFn: spawner.spawn,
|
|
316
|
+
restartDelayMs: 0,
|
|
317
|
+
sleep: () => Promise.resolve(),
|
|
318
|
+
killTimeoutMs: 1000, // Plenty of headroom for the 5ms simulated exit.
|
|
319
|
+
});
|
|
320
|
+
await sup.start({ short: "vault", cmd: ["bun", "vault.ts"] });
|
|
321
|
+
|
|
322
|
+
proc.closeStreams();
|
|
323
|
+
let exitObservedBeforeReturn = false;
|
|
324
|
+
void proc.exited.then(() => {
|
|
325
|
+
exitObservedBeforeReturn = true;
|
|
326
|
+
});
|
|
327
|
+
await sup.stop("vault");
|
|
328
|
+
|
|
329
|
+
// The exited-resolver awaited the same promise stop() did; if
|
|
330
|
+
// stop returned without awaiting, this flag could still be false.
|
|
331
|
+
// (Both promise chains fire from the same resolveExit call.
|
|
332
|
+
// Microtask ordering guarantees they both run before await returns.)
|
|
333
|
+
expect(exitObservedBeforeReturn).toBe(true);
|
|
334
|
+
expect(signals).toEqual(["SIGTERM"]);
|
|
335
|
+
expect(sup.get("vault")?.status).toBe("stopped");
|
|
336
|
+
});
|
|
263
337
|
});
|
|
264
338
|
|
|
265
339
|
describe("Supervisor.restart", () => {
|
|
@@ -4,15 +4,21 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
6
|
import {
|
|
7
|
+
PASSWORD_MIN_LEN,
|
|
7
8
|
SingleUserModeError,
|
|
9
|
+
USERNAME_RESERVED,
|
|
8
10
|
UserNotFoundError,
|
|
9
11
|
UsernameTakenError,
|
|
10
12
|
createUser,
|
|
13
|
+
deleteUser,
|
|
11
14
|
getUserById,
|
|
12
15
|
getUserByUsername,
|
|
16
|
+
getUserByUsernameCI,
|
|
13
17
|
listUsers,
|
|
14
18
|
setPassword,
|
|
15
19
|
userCount,
|
|
20
|
+
validatePassword,
|
|
21
|
+
validateUsername,
|
|
16
22
|
verifyPassword,
|
|
17
23
|
} from "../users.ts";
|
|
18
24
|
|
|
@@ -29,6 +35,11 @@ function makeDb() {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
describe("createUser", () => {
|
|
38
|
+
// Note: createUser doesn't call validatePassword directly (PR 2 wires
|
|
39
|
+
// it at the endpoint layer). These short passwords ("hunter2", "pw1",
|
|
40
|
+
// etc.) are deliberate for testing the argon2id round-trip + the
|
|
41
|
+
// INSERT shape in isolation; PR 2's endpoint tests will use full-
|
|
42
|
+
// length (12+ char) passwords against the validator-gated path.
|
|
32
43
|
test("creates a user and stores an argon2id hash", async () => {
|
|
33
44
|
const { db, cleanup } = makeDb();
|
|
34
45
|
try {
|
|
@@ -39,6 +50,50 @@ describe("createUser", () => {
|
|
|
39
50
|
expect(u.passwordHash.startsWith("$argon2id$")).toBe(true);
|
|
40
51
|
expect(u.createdAt).toBe(u.updatedAt);
|
|
41
52
|
expect(userCount(db)).toBe(1);
|
|
53
|
+
// Default multi-user-Phase-1 shape: unchanged password, no vault pin.
|
|
54
|
+
expect(u.passwordChanged).toBe(false);
|
|
55
|
+
expect(u.assignedVault).toBeNull();
|
|
56
|
+
} finally {
|
|
57
|
+
cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("passwordChanged opt-in lands the bit set (wizard / env-seed path)", async () => {
|
|
62
|
+
const { db, cleanup } = makeDb();
|
|
63
|
+
try {
|
|
64
|
+
const u = await createUser(db, "owner", "hunter2", { passwordChanged: true });
|
|
65
|
+
expect(u.passwordChanged).toBe(true);
|
|
66
|
+
// Round-trip through getUserById so we know it's persisted, not
|
|
67
|
+
// just returned by the in-memory createUser result.
|
|
68
|
+
const fresh = getUserById(db, u.id);
|
|
69
|
+
expect(fresh?.passwordChanged).toBe(true);
|
|
70
|
+
} finally {
|
|
71
|
+
cleanup();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("assignedVault opt-in persists the column (admin-creates-user path)", async () => {
|
|
76
|
+
const { db, cleanup } = makeDb();
|
|
77
|
+
try {
|
|
78
|
+
const u = await createUser(db, "alice", "pw1", {
|
|
79
|
+
allowMulti: true,
|
|
80
|
+
assignedVault: "alice",
|
|
81
|
+
});
|
|
82
|
+
expect(u.assignedVault).toBe("alice");
|
|
83
|
+
const fresh = getUserById(db, u.id);
|
|
84
|
+
expect(fresh?.assignedVault).toBe("alice");
|
|
85
|
+
} finally {
|
|
86
|
+
cleanup();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("assignedVault explicit null is treated the same as omitted", async () => {
|
|
91
|
+
const { db, cleanup } = makeDb();
|
|
92
|
+
try {
|
|
93
|
+
const u = await createUser(db, "admin1", "pw1", { assignedVault: null });
|
|
94
|
+
expect(u.assignedVault).toBeNull();
|
|
95
|
+
const fresh = getUserById(db, u.id);
|
|
96
|
+
expect(fresh?.assignedVault).toBeNull();
|
|
42
97
|
} finally {
|
|
43
98
|
cleanup();
|
|
44
99
|
}
|
|
@@ -152,3 +207,144 @@ describe("listUsers / getUserByUsername", () => {
|
|
|
152
207
|
}
|
|
153
208
|
});
|
|
154
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("getUserByUsernameCI", () => {
|
|
212
|
+
test("matches exact lowercase username", async () => {
|
|
213
|
+
const { db, cleanup } = makeDb();
|
|
214
|
+
try {
|
|
215
|
+
await createUser(db, "alice", "alice-strong-passphrase");
|
|
216
|
+
expect(getUserByUsernameCI(db, "alice")?.username).toBe("alice");
|
|
217
|
+
} finally {
|
|
218
|
+
cleanup();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("matches case-insensitively (defense in depth for legacy mixed-case rows)", async () => {
|
|
223
|
+
const { db, cleanup } = makeDb();
|
|
224
|
+
try {
|
|
225
|
+
await createUser(db, "alice", "alice-strong-passphrase");
|
|
226
|
+
expect(getUserByUsernameCI(db, "Alice")?.username).toBe("alice");
|
|
227
|
+
expect(getUserByUsernameCI(db, "ALICE")?.username).toBe("alice");
|
|
228
|
+
} finally {
|
|
229
|
+
cleanup();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("returns null when no row matches", async () => {
|
|
234
|
+
const { db, cleanup } = makeDb();
|
|
235
|
+
try {
|
|
236
|
+
expect(getUserByUsernameCI(db, "ghost")).toBeNull();
|
|
237
|
+
} finally {
|
|
238
|
+
cleanup();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("deleteUser", () => {
|
|
244
|
+
test("returns false when user does not exist", () => {
|
|
245
|
+
const { db, cleanup } = makeDb();
|
|
246
|
+
try {
|
|
247
|
+
expect(deleteUser(db, "no-such-id")).toBe(false);
|
|
248
|
+
} finally {
|
|
249
|
+
cleanup();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("returns true and drops the row", async () => {
|
|
254
|
+
const { db, cleanup } = makeDb();
|
|
255
|
+
try {
|
|
256
|
+
const u = await createUser(db, "alice", "alice-strong-passphrase");
|
|
257
|
+
expect(deleteUser(db, u.id)).toBe(true);
|
|
258
|
+
expect(getUserById(db, u.id)).toBeNull();
|
|
259
|
+
expect(userCount(db)).toBe(0);
|
|
260
|
+
} finally {
|
|
261
|
+
cleanup();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("validateUsername", () => {
|
|
267
|
+
test("happy path — typical names", () => {
|
|
268
|
+
for (const name of ["alice", "bob_42", "user-1", "ab", "a-b-c", "x_y_z"]) {
|
|
269
|
+
const r = validateUsername(name);
|
|
270
|
+
expect(r.valid).toBe(true);
|
|
271
|
+
if (r.valid) expect(r.name).toBe(name);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("length boundaries — 2 and 32 OK, 1 and 33 rejected", () => {
|
|
276
|
+
expect(validateUsername("ab").valid).toBe(true);
|
|
277
|
+
expect(validateUsername("a".repeat(32)).valid).toBe(true);
|
|
278
|
+
const tooShort = validateUsername("a");
|
|
279
|
+
expect(tooShort.valid).toBe(false);
|
|
280
|
+
if (!tooShort.valid) expect(tooShort.reason).toBe("length");
|
|
281
|
+
const tooLong = validateUsername("a".repeat(33));
|
|
282
|
+
expect(tooLong.valid).toBe(false);
|
|
283
|
+
if (!tooLong.valid) expect(tooLong.reason).toBe("length");
|
|
284
|
+
// Empty string is a length failure (not a format failure).
|
|
285
|
+
const empty = validateUsername("");
|
|
286
|
+
expect(empty.valid).toBe(false);
|
|
287
|
+
if (!empty.valid) expect(empty.reason).toBe("length");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("format failures — uppercase, spaces, symbols rejected", () => {
|
|
291
|
+
for (const name of ["Alice", "bob smith", "user@domain", "name!", "user.name", "naïve"]) {
|
|
292
|
+
const r = validateUsername(name);
|
|
293
|
+
expect(r.valid).toBe(false);
|
|
294
|
+
if (!r.valid) expect(r.reason).toBe("format");
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("reserved words rejected — case-insensitive", () => {
|
|
299
|
+
for (const reserved of USERNAME_RESERVED) {
|
|
300
|
+
const r = validateUsername(reserved);
|
|
301
|
+
expect(r.valid).toBe(false);
|
|
302
|
+
if (!r.valid) expect(r.reason).toBe("reserved");
|
|
303
|
+
}
|
|
304
|
+
// Case variants — the regex pins lowercase so uppercase fails as
|
|
305
|
+
// "format" first; the case-insensitive reserved check is defense in
|
|
306
|
+
// depth. But within the lowercase-allowed set, mixed-case-spelled
|
|
307
|
+
// reserved words are blocked by the format gate (e.g. "Admin"
|
|
308
|
+
// fails format, not reserved). The regex catches case variants
|
|
309
|
+
// before reserved-check ever runs — that's correct order.
|
|
310
|
+
const mixed = validateUsername("Admin");
|
|
311
|
+
expect(mixed.valid).toBe(false);
|
|
312
|
+
if (!mixed.valid) expect(mixed.reason).toBe("format");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("hyphens and underscores allowed; numbers allowed", () => {
|
|
316
|
+
expect(validateUsername("user_1").valid).toBe(true);
|
|
317
|
+
expect(validateUsername("user-2").valid).toBe(true);
|
|
318
|
+
expect(validateUsername("123").valid).toBe(true);
|
|
319
|
+
expect(validateUsername("_-_").valid).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("validatePassword", () => {
|
|
324
|
+
test("happy path — 12+ chars accepted", () => {
|
|
325
|
+
expect(validatePassword("twelvechars1").valid).toBe(true);
|
|
326
|
+
expect(validatePassword("a much longer passphrase here").valid).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("boundary — exactly 12 chars accepted, 11 rejected", () => {
|
|
330
|
+
expect(PASSWORD_MIN_LEN).toBe(12);
|
|
331
|
+
expect(validatePassword("a".repeat(12)).valid).toBe(true);
|
|
332
|
+
const r = validatePassword("a".repeat(11));
|
|
333
|
+
expect(r.valid).toBe(false);
|
|
334
|
+
if (!r.valid) expect(r.reason).toBe("too_short");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("empty string rejected as too_short", () => {
|
|
338
|
+
const r = validatePassword("");
|
|
339
|
+
expect(r.valid).toBe(false);
|
|
340
|
+
if (!r.valid) expect(r.reason).toBe("too_short");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("no complexity rules — long but all-same-char accepted", () => {
|
|
344
|
+
// Phase 1 takes NIST 800-63B's lead: length over forced classes.
|
|
345
|
+
// Aaron settled on 12-min, no complexity. If we later want to nudge
|
|
346
|
+
// toward passphrases we'll layer it as a separate signal, not a
|
|
347
|
+
// hard gate.
|
|
348
|
+
expect(validatePassword("aaaaaaaaaaaa").valid).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault-name validator tests (hub#267). Mirrors the vault repo's
|
|
3
|
+
* `vault-name.test.ts` — hub keeps its own copy because it doesn't
|
|
4
|
+
* depend on @openparachute/vault at runtime. The two must stay in
|
|
5
|
+
* lockstep so the typed name hub validates is the one vault accepts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { DEFAULT_VAULT_NAME, validateVaultName } from "../vault-name.ts";
|
|
10
|
+
|
|
11
|
+
describe("validateVaultName", () => {
|
|
12
|
+
test("accepts lowercase alphanumeric + hyphens/underscores", () => {
|
|
13
|
+
const cases = ["aaron", "my-vault", "smoke_2026", "abc", "vault123", "a-b_c-d"];
|
|
14
|
+
for (const name of cases) {
|
|
15
|
+
const result = validateVaultName(name);
|
|
16
|
+
expect(result.ok).toBe(true);
|
|
17
|
+
if (result.ok) expect(result.name).toBe(name);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("trims surrounding whitespace", () => {
|
|
22
|
+
const result = validateVaultName(" aaron ");
|
|
23
|
+
expect(result.ok).toBe(true);
|
|
24
|
+
if (result.ok) expect(result.name).toBe("aaron");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejects uppercase letters", () => {
|
|
28
|
+
const result = validateVaultName("Aaron");
|
|
29
|
+
expect(result.ok).toBe(false);
|
|
30
|
+
if (!result.ok) expect(result.error).toContain("lowercase alphanumeric");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects spaces", () => {
|
|
34
|
+
const result = validateVaultName("my vault");
|
|
35
|
+
expect(result.ok).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects special characters", () => {
|
|
39
|
+
for (const name of ["my!vault", "vault.dot", "vault/slash", "vault@home"]) {
|
|
40
|
+
const result = validateVaultName(name);
|
|
41
|
+
expect(result.ok).toBe(false);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects too-short names (< 2 chars)", () => {
|
|
46
|
+
const result = validateVaultName("a");
|
|
47
|
+
expect(result.ok).toBe(false);
|
|
48
|
+
if (!result.ok) expect(result.error).toContain("2");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects too-long names (> 32 chars)", () => {
|
|
52
|
+
const result = validateVaultName("a".repeat(33));
|
|
53
|
+
expect(result.ok).toBe(false);
|
|
54
|
+
if (!result.ok) expect(result.error).toContain("32");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("accepts boundary lengths (2 and 32)", () => {
|
|
58
|
+
expect(validateVaultName("ab").ok).toBe(true);
|
|
59
|
+
expect(validateVaultName("a".repeat(32)).ok).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects empty / whitespace-only names", () => {
|
|
63
|
+
expect(validateVaultName("").ok).toBe(false);
|
|
64
|
+
expect(validateVaultName(" ").ok).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects the reserved name 'list' (matches vault's reservation)", () => {
|
|
68
|
+
const result = validateVaultName("list");
|
|
69
|
+
expect(result.ok).toBe(false);
|
|
70
|
+
if (!result.ok) expect(result.error).toContain("reserved");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("DEFAULT_VAULT_NAME is 'default'", () => {
|
|
74
|
+
expect(DEFAULT_VAULT_NAME).toBe("default");
|
|
75
|
+
// And it passes the validator (sanity check — vault uses this as
|
|
76
|
+
// the canonical fallback).
|
|
77
|
+
expect(validateVaultName(DEFAULT_VAULT_NAME).ok).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared `vault-names.ts` helper (multi-user Phase 1, PR 4).
|
|
3
|
+
*
|
|
4
|
+
* The shared helper lifted the two pre-PR-4 private copies (`oauth-handlers.ts`
|
|
5
|
+
* + `api-users.ts`) into one place. These tests pin the canonical behavior so
|
|
6
|
+
* both callers — the OAuth consent picker + the admin SPA's assigned-vault
|
|
7
|
+
* dropdown + the server-side defense in `handleConsentSubmit` — see the same
|
|
8
|
+
* name set.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { ServicesManifest } from "../services-manifest.ts";
|
|
15
|
+
import { listVaultNames, listVaultNamesFromPath } from "../vault-names.ts";
|
|
16
|
+
|
|
17
|
+
describe("listVaultNames", () => {
|
|
18
|
+
test("returns empty list when no vault services are registered", () => {
|
|
19
|
+
const manifest: ServicesManifest = {
|
|
20
|
+
services: [
|
|
21
|
+
{ name: "parachute-notes", port: 1942, paths: ["/notes"], health: "/h", version: "0.1.0" },
|
|
22
|
+
{
|
|
23
|
+
name: "parachute-scribe",
|
|
24
|
+
port: 1943,
|
|
25
|
+
paths: ["/scribe"],
|
|
26
|
+
health: "/h",
|
|
27
|
+
version: "0.1.0",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
expect(listVaultNames(manifest)).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("single-entry-multi-path: emits one name per `/vault/<name>` path", () => {
|
|
35
|
+
const manifest: ServicesManifest = {
|
|
36
|
+
services: [
|
|
37
|
+
{
|
|
38
|
+
name: "parachute-vault",
|
|
39
|
+
port: 1940,
|
|
40
|
+
paths: ["/vault/work", "/vault/personal"],
|
|
41
|
+
health: "/h",
|
|
42
|
+
version: "0.1.0",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("per-vault entries: emits one name per `parachute-vault-<name>` entry", () => {
|
|
50
|
+
const manifest: ServicesManifest = {
|
|
51
|
+
services: [
|
|
52
|
+
{
|
|
53
|
+
name: "parachute-vault-work",
|
|
54
|
+
port: 1940,
|
|
55
|
+
paths: ["/vault/work"],
|
|
56
|
+
health: "/h",
|
|
57
|
+
version: "0.1.0",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "parachute-vault-personal",
|
|
61
|
+
port: 1941,
|
|
62
|
+
paths: ["/vault/personal"],
|
|
63
|
+
health: "/h",
|
|
64
|
+
version: "0.1.0",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("entry with no paths falls back to the manifest-suffix name (hub#143)", () => {
|
|
72
|
+
const manifest: ServicesManifest = {
|
|
73
|
+
services: [
|
|
74
|
+
{
|
|
75
|
+
name: "parachute-vault-archived",
|
|
76
|
+
port: 1940,
|
|
77
|
+
paths: [],
|
|
78
|
+
health: "/h",
|
|
79
|
+
version: "0.1.0",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
expect(listVaultNames(manifest)).toEqual(["archived"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("deduplicates collisions across single-entry + per-vault shapes", () => {
|
|
87
|
+
const manifest: ServicesManifest = {
|
|
88
|
+
services: [
|
|
89
|
+
{
|
|
90
|
+
name: "parachute-vault",
|
|
91
|
+
port: 1940,
|
|
92
|
+
paths: ["/vault/work"],
|
|
93
|
+
health: "/h",
|
|
94
|
+
version: "0.1.0",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "parachute-vault-work",
|
|
98
|
+
port: 1941,
|
|
99
|
+
paths: ["/vault/work"],
|
|
100
|
+
health: "/h",
|
|
101
|
+
version: "0.1.0",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
expect(listVaultNames(manifest)).toEqual(["work"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("output is sorted ascending — deterministic order for both callers", () => {
|
|
109
|
+
const manifest: ServicesManifest = {
|
|
110
|
+
services: [
|
|
111
|
+
{
|
|
112
|
+
name: "parachute-vault",
|
|
113
|
+
port: 1940,
|
|
114
|
+
paths: ["/vault/zeta", "/vault/alpha", "/vault/middle"],
|
|
115
|
+
health: "/h",
|
|
116
|
+
version: "0.1.0",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
expect(listVaultNames(manifest)).toEqual(["alpha", "middle", "zeta"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("listVaultNamesFromPath", () => {
|
|
125
|
+
test("reads from a services.json file and emits the same names", () => {
|
|
126
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-vault-names-"));
|
|
127
|
+
const path = join(dir, "services.json");
|
|
128
|
+
writeFileSync(
|
|
129
|
+
path,
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
services: [
|
|
132
|
+
{
|
|
133
|
+
name: "parachute-vault",
|
|
134
|
+
port: 1940,
|
|
135
|
+
paths: ["/vault/work"],
|
|
136
|
+
health: "/h",
|
|
137
|
+
version: "0.1.0",
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
try {
|
|
143
|
+
expect(listVaultNamesFromPath(path)).toEqual(["work"]);
|
|
144
|
+
} finally {
|
|
145
|
+
rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("api-users.ts and oauth-handlers.ts read the same source through listVaultNamesFromPath / listVaultNames", () => {
|
|
150
|
+
// Cross-caller parity: the helper is the single source. Both code paths
|
|
151
|
+
// should see byte-identical output against the same services.json.
|
|
152
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-vault-names-parity-"));
|
|
153
|
+
const path = join(dir, "services.json");
|
|
154
|
+
const manifest: ServicesManifest = {
|
|
155
|
+
services: [
|
|
156
|
+
{
|
|
157
|
+
name: "parachute-vault",
|
|
158
|
+
port: 1940,
|
|
159
|
+
paths: ["/vault/work", "/vault/personal"],
|
|
160
|
+
health: "/h",
|
|
161
|
+
version: "0.1.0",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
writeFileSync(path, JSON.stringify(manifest));
|
|
166
|
+
try {
|
|
167
|
+
expect(listVaultNamesFromPath(path)).toEqual(listVaultNames(manifest));
|
|
168
|
+
} finally {
|
|
169
|
+
rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|