@openparachute/hub 0.5.13 → 0.5.14-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -123,7 +123,7 @@ describe("setup", () => {
|
|
|
123
123
|
{ name: "parachute-scribe", port: 1943 },
|
|
124
124
|
{ name: "parachute-channel", port: 1941 },
|
|
125
125
|
{ name: "parachute-runner", port: 1945 },
|
|
126
|
-
{ name: "parachute-
|
|
126
|
+
{ name: "parachute-surface", port: 1946 },
|
|
127
127
|
];
|
|
128
128
|
for (const s of seeds) {
|
|
129
129
|
upsertService(
|
|
@@ -123,6 +123,45 @@ describe("status", () => {
|
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test("http 401 counts as HEALTHY (auth-gated endpoint is responsive)", async () => {
|
|
127
|
+
// Vault's canonical health path `/vault/<name>/health` returns 401
|
|
128
|
+
// without an API key — that's the server replying "I'm up but you
|
|
129
|
+
// need auth," not "I'm down." `parachute status` used to roll 401
|
|
130
|
+
// into the failing bucket via `res.ok`, surfacing "failing" on every
|
|
131
|
+
// fresh install (vault was fine — the probe was just confused).
|
|
132
|
+
// Now: 401 specifically counts as healthy. Other 4xx (404, 400) stay
|
|
133
|
+
// unhealthy — those mean the configured health path is misshapen.
|
|
134
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
135
|
+
try {
|
|
136
|
+
upsertService(
|
|
137
|
+
{
|
|
138
|
+
name: "parachute-vault",
|
|
139
|
+
port: 1940,
|
|
140
|
+
paths: ["/"],
|
|
141
|
+
health: "/vault/default/health",
|
|
142
|
+
version: "0.2.4",
|
|
143
|
+
},
|
|
144
|
+
path,
|
|
145
|
+
);
|
|
146
|
+
writePid("vault", 4242, configDir);
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
const code = await status({
|
|
149
|
+
manifestPath: path,
|
|
150
|
+
configDir,
|
|
151
|
+
alive: () => true,
|
|
152
|
+
fetchImpl: async () => new Response(null, { status: 401 }),
|
|
153
|
+
print: (l) => lines.push(l),
|
|
154
|
+
});
|
|
155
|
+
expect(code).toBe(0);
|
|
156
|
+
expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
|
|
157
|
+
// No "failing" rollup, no `! probe: http 401` continuation line.
|
|
158
|
+
expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(false);
|
|
159
|
+
expect(lines.some((l) => l.includes("probe: http 401"))).toBe(false);
|
|
160
|
+
} finally {
|
|
161
|
+
cleanup();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
126
165
|
test("running + healthy probe shows STATE=active, pid + uptime", async () => {
|
|
127
166
|
const { path, configDir, cleanup } = makeTempPath();
|
|
128
167
|
try {
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
|
+
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
6
7
|
import {
|
|
7
8
|
PASSWORD_MIN_LEN,
|
|
8
9
|
SingleUserModeError,
|
|
@@ -11,11 +12,15 @@ import {
|
|
|
11
12
|
UsernameTakenError,
|
|
12
13
|
createUser,
|
|
13
14
|
deleteUser,
|
|
15
|
+
getFirstAdminId,
|
|
14
16
|
getUserById,
|
|
15
17
|
getUserByUsername,
|
|
16
18
|
getUserByUsernameCI,
|
|
19
|
+
isFirstAdmin,
|
|
17
20
|
listUsers,
|
|
21
|
+
resetUserPassword,
|
|
18
22
|
setPassword,
|
|
23
|
+
setUserVaults,
|
|
19
24
|
userCount,
|
|
20
25
|
validatePassword,
|
|
21
26
|
validateUsername,
|
|
@@ -52,7 +57,7 @@ describe("createUser", () => {
|
|
|
52
57
|
expect(userCount(db)).toBe(1);
|
|
53
58
|
// Default multi-user-Phase-1 shape: unchanged password, no vault pin.
|
|
54
59
|
expect(u.passwordChanged).toBe(false);
|
|
55
|
-
expect(u.
|
|
60
|
+
expect(u.assignedVaults).toEqual([]);
|
|
56
61
|
} finally {
|
|
57
62
|
cleanup();
|
|
58
63
|
}
|
|
@@ -72,28 +77,63 @@ describe("createUser", () => {
|
|
|
72
77
|
}
|
|
73
78
|
});
|
|
74
79
|
|
|
75
|
-
test("
|
|
80
|
+
test("assignedVaults opt-in persists each vault (admin-creates-user path)", async () => {
|
|
76
81
|
const { db, cleanup } = makeDb();
|
|
77
82
|
try {
|
|
78
83
|
const u = await createUser(db, "alice", "pw1", {
|
|
79
84
|
allowMulti: true,
|
|
80
|
-
|
|
85
|
+
assignedVaults: ["alice"],
|
|
81
86
|
});
|
|
82
|
-
expect(u.
|
|
87
|
+
expect(u.assignedVaults).toEqual(["alice"]);
|
|
83
88
|
const fresh = getUserById(db, u.id);
|
|
84
|
-
expect(fresh?.
|
|
89
|
+
expect(fresh?.assignedVaults).toEqual(["alice"]);
|
|
85
90
|
} finally {
|
|
86
91
|
cleanup();
|
|
87
92
|
}
|
|
88
93
|
});
|
|
89
94
|
|
|
90
|
-
test("
|
|
95
|
+
test("assignedVaults with multiple entries — all persist (multi-vault Phase 2)", async () => {
|
|
91
96
|
const { db, cleanup } = makeDb();
|
|
92
97
|
try {
|
|
93
|
-
const u = await createUser(db, "
|
|
94
|
-
|
|
98
|
+
const u = await createUser(db, "alice", "pw1", {
|
|
99
|
+
allowMulti: true,
|
|
100
|
+
assignedVaults: ["personal", "family"],
|
|
101
|
+
});
|
|
102
|
+
expect(u.assignedVaults).toEqual(["personal", "family"]);
|
|
103
|
+
const fresh = getUserById(db, u.id);
|
|
104
|
+
// Loaded via the user_vaults JOIN — order matches insertion order
|
|
105
|
+
// because rows share the same `created_at` timestamp and tie-break
|
|
106
|
+
// on `vault_name` ASC. `family` precedes `personal` alphabetically.
|
|
107
|
+
expect(fresh?.assignedVaults.length).toBe(2);
|
|
108
|
+
expect(new Set(fresh?.assignedVaults)).toEqual(new Set(["personal", "family"]));
|
|
109
|
+
} finally {
|
|
110
|
+
cleanup();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("assignedVaults omitted defaults to empty array (admin posture)", async () => {
|
|
115
|
+
const { db, cleanup } = makeDb();
|
|
116
|
+
try {
|
|
117
|
+
const u = await createUser(db, "admin1", "pw1");
|
|
118
|
+
expect(u.assignedVaults).toEqual([]);
|
|
95
119
|
const fresh = getUserById(db, u.id);
|
|
96
|
-
expect(fresh?.
|
|
120
|
+
expect(fresh?.assignedVaults).toEqual([]);
|
|
121
|
+
} finally {
|
|
122
|
+
cleanup();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("assignedVaults de-duplicates repeated names", async () => {
|
|
127
|
+
const { db, cleanup } = makeDb();
|
|
128
|
+
try {
|
|
129
|
+
const u = await createUser(db, "alice", "pw1", {
|
|
130
|
+
allowMulti: true,
|
|
131
|
+
assignedVaults: ["personal", "personal", "family"],
|
|
132
|
+
});
|
|
133
|
+
// De-dupe is silent; user gets one row per distinct name.
|
|
134
|
+
expect(u.assignedVaults).toEqual(["personal", "family"]);
|
|
135
|
+
const fresh = getUserById(db, u.id);
|
|
136
|
+
expect(fresh?.assignedVaults.length).toBe(2);
|
|
97
137
|
} finally {
|
|
98
138
|
cleanup();
|
|
99
139
|
}
|
|
@@ -348,3 +388,350 @@ describe("validatePassword", () => {
|
|
|
348
388
|
expect(validatePassword("aaaaaaaaaaaa").valid).toBe(true);
|
|
349
389
|
});
|
|
350
390
|
});
|
|
391
|
+
|
|
392
|
+
describe("resetUserPassword", () => {
|
|
393
|
+
test("returns false when user does not exist", async () => {
|
|
394
|
+
const { db, cleanup } = makeDb();
|
|
395
|
+
try {
|
|
396
|
+
expect(await resetUserPassword(db, "no-such-id", "twelvechars1")).toBe(false);
|
|
397
|
+
} finally {
|
|
398
|
+
cleanup();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("returns false when user vanishes between pre-check and tx body", async () => {
|
|
403
|
+
// Reviewer-flagged race path (hub#427). The argon2 hash is computed
|
|
404
|
+
// outside the transaction (async), giving a window where a concurrent
|
|
405
|
+
// delete can land between the existence pre-check and the UPDATE tx.
|
|
406
|
+
// The helper must return false in that case so the caller can 404
|
|
407
|
+
// instead of cosmetically claiming success.
|
|
408
|
+
const { db, cleanup } = makeDb();
|
|
409
|
+
try {
|
|
410
|
+
const user = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
411
|
+
passwordChanged: true,
|
|
412
|
+
});
|
|
413
|
+
// Simulate the race: delete the row, then invoke the reset. The
|
|
414
|
+
// pre-check runs in `resetUserPassword` against the now-empty table.
|
|
415
|
+
// (We can't intercept between pre-check and tx without forking the
|
|
416
|
+
// helper; deleting before the call is the equivalent post-condition
|
|
417
|
+
// — if the row is gone the tx body will UPDATE 0 rows.)
|
|
418
|
+
db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
|
|
419
|
+
expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(false);
|
|
420
|
+
} finally {
|
|
421
|
+
cleanup();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("rotates hash, flips password_changed back to 0, bumps updated_at", async () => {
|
|
426
|
+
const { db, cleanup } = makeDb();
|
|
427
|
+
try {
|
|
428
|
+
// Seed user as "already changed their password" (true) to prove the
|
|
429
|
+
// reset flips it back to false for the force-redirect rail.
|
|
430
|
+
const initial = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
431
|
+
passwordChanged: true,
|
|
432
|
+
now: () => new Date(1000),
|
|
433
|
+
});
|
|
434
|
+
const oldHash = initial.passwordHash;
|
|
435
|
+
const oldUpdated = initial.updatedAt;
|
|
436
|
+
const later = new Date(2000);
|
|
437
|
+
expect(await resetUserPassword(db, initial.id, "new-temp-passphrase", () => later)).toBe(
|
|
438
|
+
true,
|
|
439
|
+
);
|
|
440
|
+
const fresh = getUserById(db, initial.id);
|
|
441
|
+
expect(fresh).not.toBeNull();
|
|
442
|
+
expect(fresh?.passwordHash).not.toBe(oldHash);
|
|
443
|
+
expect(fresh?.passwordChanged).toBe(false);
|
|
444
|
+
expect(fresh?.updatedAt).not.toBe(oldUpdated);
|
|
445
|
+
// Round-trip verify: old password no longer works, new one does.
|
|
446
|
+
expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
|
|
447
|
+
expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
|
|
448
|
+
} finally {
|
|
449
|
+
cleanup();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("revokes still-active tokens belonging to the user", async () => {
|
|
454
|
+
const { db, cleanup } = makeDb();
|
|
455
|
+
try {
|
|
456
|
+
const user = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
457
|
+
passwordChanged: true,
|
|
458
|
+
});
|
|
459
|
+
const minted = await signAccessToken(db, {
|
|
460
|
+
sub: user.id,
|
|
461
|
+
scopes: ["vault:home:read"],
|
|
462
|
+
audience: "vault",
|
|
463
|
+
clientId: "notes-client",
|
|
464
|
+
issuer: "https://hub.test",
|
|
465
|
+
ttlSeconds: 600,
|
|
466
|
+
});
|
|
467
|
+
recordTokenMint(db, {
|
|
468
|
+
jti: minted.jti,
|
|
469
|
+
createdVia: "operator_mint",
|
|
470
|
+
subject: user.username,
|
|
471
|
+
userId: user.id,
|
|
472
|
+
clientId: "notes-client",
|
|
473
|
+
scopes: ["vault:home:read"],
|
|
474
|
+
expiresAt: minted.expiresAt,
|
|
475
|
+
});
|
|
476
|
+
// Pre-state: token row not yet revoked.
|
|
477
|
+
const before = db
|
|
478
|
+
.query<{ revoked_at: string | null }, [string]>(
|
|
479
|
+
"SELECT revoked_at FROM tokens WHERE jti = ?",
|
|
480
|
+
)
|
|
481
|
+
.get(minted.jti);
|
|
482
|
+
expect(before?.revoked_at).toBeNull();
|
|
483
|
+
|
|
484
|
+
expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(true);
|
|
485
|
+
|
|
486
|
+
// Post-state: token row has revoked_at set, user_id retained (the
|
|
487
|
+
// user row sticks around, audit trail re-anchors naturally).
|
|
488
|
+
const after = db
|
|
489
|
+
.query<{ revoked_at: string | null; user_id: string | null }, [string]>(
|
|
490
|
+
"SELECT revoked_at, user_id FROM tokens WHERE jti = ?",
|
|
491
|
+
)
|
|
492
|
+
.get(minted.jti);
|
|
493
|
+
expect(after?.revoked_at).not.toBeNull();
|
|
494
|
+
expect(after?.user_id).toBe(user.id);
|
|
495
|
+
} finally {
|
|
496
|
+
cleanup();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("does not re-revoke an already-revoked token", async () => {
|
|
501
|
+
// Defense-in-depth: a previously-revoked token shouldn't have its
|
|
502
|
+
// revoked_at timestamp overwritten by a fresh reset. The UPDATE's
|
|
503
|
+
// WHERE clause filters on `revoked_at IS NULL` so this is naturally
|
|
504
|
+
// enforced; pinning it here so a future refactor that drops the
|
|
505
|
+
// filter trips the test.
|
|
506
|
+
const { db, cleanup } = makeDb();
|
|
507
|
+
try {
|
|
508
|
+
const user = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
509
|
+
passwordChanged: true,
|
|
510
|
+
});
|
|
511
|
+
const minted = await signAccessToken(db, {
|
|
512
|
+
sub: user.id,
|
|
513
|
+
scopes: ["vault:home:read"],
|
|
514
|
+
audience: "vault",
|
|
515
|
+
clientId: "notes-client",
|
|
516
|
+
issuer: "https://hub.test",
|
|
517
|
+
ttlSeconds: 600,
|
|
518
|
+
});
|
|
519
|
+
const earlierStamp = "2026-01-01T00:00:00.000Z";
|
|
520
|
+
recordTokenMint(db, {
|
|
521
|
+
jti: minted.jti,
|
|
522
|
+
createdVia: "operator_mint",
|
|
523
|
+
subject: user.username,
|
|
524
|
+
userId: user.id,
|
|
525
|
+
clientId: "notes-client",
|
|
526
|
+
scopes: ["vault:home:read"],
|
|
527
|
+
expiresAt: minted.expiresAt,
|
|
528
|
+
});
|
|
529
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(earlierStamp, minted.jti);
|
|
530
|
+
|
|
531
|
+
expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(true);
|
|
532
|
+
|
|
533
|
+
const row = db
|
|
534
|
+
.query<{ revoked_at: string | null }, [string]>(
|
|
535
|
+
"SELECT revoked_at FROM tokens WHERE jti = ?",
|
|
536
|
+
)
|
|
537
|
+
.get(minted.jti);
|
|
538
|
+
expect(row?.revoked_at).toBe(earlierStamp);
|
|
539
|
+
} finally {
|
|
540
|
+
cleanup();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("leaves tokens for other users untouched", async () => {
|
|
545
|
+
const { db, cleanup } = makeDb();
|
|
546
|
+
try {
|
|
547
|
+
const alice = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
548
|
+
passwordChanged: true,
|
|
549
|
+
});
|
|
550
|
+
const bob = await createUser(db, "bob", "bob-strong-passphrase", {
|
|
551
|
+
allowMulti: true,
|
|
552
|
+
passwordChanged: true,
|
|
553
|
+
});
|
|
554
|
+
const bobToken = await signAccessToken(db, {
|
|
555
|
+
sub: bob.id,
|
|
556
|
+
scopes: ["vault:home:read"],
|
|
557
|
+
audience: "vault",
|
|
558
|
+
clientId: "notes-client",
|
|
559
|
+
issuer: "https://hub.test",
|
|
560
|
+
ttlSeconds: 600,
|
|
561
|
+
});
|
|
562
|
+
recordTokenMint(db, {
|
|
563
|
+
jti: bobToken.jti,
|
|
564
|
+
createdVia: "operator_mint",
|
|
565
|
+
subject: bob.username,
|
|
566
|
+
userId: bob.id,
|
|
567
|
+
clientId: "notes-client",
|
|
568
|
+
scopes: ["vault:home:read"],
|
|
569
|
+
expiresAt: bobToken.expiresAt,
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await resetUserPassword(db, alice.id, "new-temp-passphrase");
|
|
573
|
+
|
|
574
|
+
const bobRow = db
|
|
575
|
+
.query<{ revoked_at: string | null }, [string]>(
|
|
576
|
+
"SELECT revoked_at FROM tokens WHERE jti = ?",
|
|
577
|
+
)
|
|
578
|
+
.get(bobToken.jti);
|
|
579
|
+
expect(bobRow?.revoked_at).toBeNull();
|
|
580
|
+
} finally {
|
|
581
|
+
cleanup();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe("setUserVaults (multi-user Phase 2 PR 2)", () => {
|
|
587
|
+
test("returns false when user does not exist", () => {
|
|
588
|
+
const { db, cleanup } = makeDb();
|
|
589
|
+
try {
|
|
590
|
+
expect(setUserVaults(db, "no-such-id", ["a"])).toBe(false);
|
|
591
|
+
} finally {
|
|
592
|
+
cleanup();
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("replaces a user's vault list atomically", async () => {
|
|
597
|
+
const { db, cleanup } = makeDb();
|
|
598
|
+
try {
|
|
599
|
+
const u = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
600
|
+
assignedVaults: ["personal"],
|
|
601
|
+
});
|
|
602
|
+
expect(setUserVaults(db, u.id, ["family", "work"])).toBe(true);
|
|
603
|
+
const fresh = getUserById(db, u.id);
|
|
604
|
+
// Old vault dropped; new ones present.
|
|
605
|
+
expect(new Set(fresh?.assignedVaults)).toEqual(new Set(["family", "work"]));
|
|
606
|
+
expect(fresh?.assignedVaults).not.toContain("personal");
|
|
607
|
+
} finally {
|
|
608
|
+
cleanup();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("empty array clears every existing assignment (non-admin = no access)", async () => {
|
|
613
|
+
const { db, cleanup } = makeDb();
|
|
614
|
+
try {
|
|
615
|
+
const u = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
616
|
+
assignedVaults: ["a", "b", "c"],
|
|
617
|
+
});
|
|
618
|
+
expect(setUserVaults(db, u.id, [])).toBe(true);
|
|
619
|
+
const fresh = getUserById(db, u.id);
|
|
620
|
+
expect(fresh?.assignedVaults).toEqual([]);
|
|
621
|
+
} finally {
|
|
622
|
+
cleanup();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("de-duplicates repeated names without throwing", async () => {
|
|
627
|
+
const { db, cleanup } = makeDb();
|
|
628
|
+
try {
|
|
629
|
+
const u = await createUser(db, "alice", "alice-strong-passphrase");
|
|
630
|
+
expect(setUserVaults(db, u.id, ["a", "a", "b"])).toBe(true);
|
|
631
|
+
const fresh = getUserById(db, u.id);
|
|
632
|
+
expect(new Set(fresh?.assignedVaults)).toEqual(new Set(["a", "b"]));
|
|
633
|
+
expect(fresh?.assignedVaults.length).toBe(2);
|
|
634
|
+
} finally {
|
|
635
|
+
cleanup();
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("bumps updated_at so the SPA row reflects the change", async () => {
|
|
640
|
+
const { db, cleanup } = makeDb();
|
|
641
|
+
try {
|
|
642
|
+
const u = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
643
|
+
now: () => new Date(1000),
|
|
644
|
+
});
|
|
645
|
+
const before = getUserById(db, u.id);
|
|
646
|
+
expect(setUserVaults(db, u.id, ["a"], () => new Date(2000))).toBe(true);
|
|
647
|
+
const after = getUserById(db, u.id);
|
|
648
|
+
expect(after?.updatedAt).not.toBe(before?.updatedAt);
|
|
649
|
+
} finally {
|
|
650
|
+
cleanup();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("vault assignments cascade-delete with the user row", async () => {
|
|
655
|
+
const { db, cleanup } = makeDb();
|
|
656
|
+
try {
|
|
657
|
+
const u = await createUser(db, "alice", "alice-strong-passphrase", {
|
|
658
|
+
assignedVaults: ["a", "b"],
|
|
659
|
+
});
|
|
660
|
+
// Sanity: rows exist in user_vaults.
|
|
661
|
+
const before = db
|
|
662
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
|
|
663
|
+
.get(u.id);
|
|
664
|
+
expect(before?.n).toBe(2);
|
|
665
|
+
deleteUser(db, u.id);
|
|
666
|
+
const after = db
|
|
667
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
|
|
668
|
+
.get(u.id);
|
|
669
|
+
expect(after?.n).toBe(0);
|
|
670
|
+
} finally {
|
|
671
|
+
cleanup();
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
describe("getFirstAdminId / isFirstAdmin", () => {
|
|
677
|
+
test("getFirstAdminId returns null on an empty users table", () => {
|
|
678
|
+
const { db, cleanup } = makeDb();
|
|
679
|
+
try {
|
|
680
|
+
expect(getFirstAdminId(db)).toBeNull();
|
|
681
|
+
} finally {
|
|
682
|
+
cleanup();
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("getFirstAdminId returns the earliest-created user id", async () => {
|
|
687
|
+
const { db, cleanup } = makeDb();
|
|
688
|
+
try {
|
|
689
|
+
const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
|
|
690
|
+
await createUser(db, "alice", "pw2", {
|
|
691
|
+
allowMulti: true,
|
|
692
|
+
now: () => new Date(2000),
|
|
693
|
+
});
|
|
694
|
+
await createUser(db, "bob", "pw3", {
|
|
695
|
+
allowMulti: true,
|
|
696
|
+
now: () => new Date(3000),
|
|
697
|
+
});
|
|
698
|
+
expect(getFirstAdminId(db)).toBe(admin.id);
|
|
699
|
+
} finally {
|
|
700
|
+
cleanup();
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("isFirstAdmin matches earliest user, false for everyone else", async () => {
|
|
705
|
+
const { db, cleanup } = makeDb();
|
|
706
|
+
try {
|
|
707
|
+
const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
|
|
708
|
+
const friend = await createUser(db, "alice", "pw2", {
|
|
709
|
+
allowMulti: true,
|
|
710
|
+
now: () => new Date(2000),
|
|
711
|
+
});
|
|
712
|
+
expect(isFirstAdmin(db, admin.id)).toBe(true);
|
|
713
|
+
expect(isFirstAdmin(db, friend.id)).toBe(false);
|
|
714
|
+
expect(isFirstAdmin(db, "no-such-id")).toBe(false);
|
|
715
|
+
} finally {
|
|
716
|
+
cleanup();
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("isFirstAdmin tracks the admin even after a later user is deleted", async () => {
|
|
721
|
+
// Deleting a non-first user doesn't promote anyone — the original
|
|
722
|
+
// admin still holds the "first" slot.
|
|
723
|
+
const { db, cleanup } = makeDb();
|
|
724
|
+
try {
|
|
725
|
+
const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
|
|
726
|
+
const friend = await createUser(db, "alice", "pw2", {
|
|
727
|
+
allowMulti: true,
|
|
728
|
+
now: () => new Date(2000),
|
|
729
|
+
});
|
|
730
|
+
deleteUser(db, friend.id);
|
|
731
|
+
expect(getFirstAdminId(db)).toBe(admin.id);
|
|
732
|
+
expect(isFirstAdmin(db, admin.id)).toBe(true);
|
|
733
|
+
} finally {
|
|
734
|
+
cleanup();
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
});
|
|
@@ -426,10 +426,10 @@ describe("buildWellKnown", () => {
|
|
|
426
426
|
// joined onto the canonical origin into a deep-linkable `url`.
|
|
427
427
|
describe("uis hierarchical sub-units (hub#313)", () => {
|
|
428
428
|
const app: ServiceEntry = {
|
|
429
|
-
name: "parachute-
|
|
429
|
+
name: "parachute-surface",
|
|
430
430
|
port: 1946,
|
|
431
|
-
paths: ["/
|
|
432
|
-
health: "/
|
|
431
|
+
paths: ["/surface"],
|
|
432
|
+
health: "/surface/healthz",
|
|
433
433
|
version: "0.1.0",
|
|
434
434
|
};
|
|
435
435
|
|
|
@@ -455,7 +455,7 @@ describe("buildWellKnown", () => {
|
|
|
455
455
|
services: [withUis],
|
|
456
456
|
canonicalOrigin: "https://x.example",
|
|
457
457
|
});
|
|
458
|
-
const appSvc = doc.services.find((s) => s.name === "parachute-
|
|
458
|
+
const appSvc = doc.services.find((s) => s.name === "parachute-surface");
|
|
459
459
|
expect(appSvc?.uis).toEqual([
|
|
460
460
|
{
|
|
461
461
|
name: "gitcoin-brain",
|
|
@@ -500,7 +500,7 @@ describe("buildWellKnown", () => {
|
|
|
500
500
|
services: [empty],
|
|
501
501
|
canonicalOrigin: "https://x.example",
|
|
502
502
|
});
|
|
503
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
503
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
504
504
|
expect(svc).not.toHaveProperty("uis");
|
|
505
505
|
});
|
|
506
506
|
|
|
@@ -519,7 +519,7 @@ describe("buildWellKnown", () => {
|
|
|
519
519
|
services: [withIcon],
|
|
520
520
|
canonicalOrigin: "https://x.example",
|
|
521
521
|
});
|
|
522
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
522
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
523
523
|
expect(svc?.uis?.[0]?.iconUrl).toBe("https://x.example/app/slug/icon.svg");
|
|
524
524
|
});
|
|
525
525
|
|
|
@@ -538,7 +538,7 @@ describe("buildWellKnown", () => {
|
|
|
538
538
|
services: [withIcon],
|
|
539
539
|
canonicalOrigin: "https://x.example",
|
|
540
540
|
});
|
|
541
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
541
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
542
542
|
expect(svc?.uis?.[0]?.iconUrl).toBe("https://cdn.example.com/icon.svg");
|
|
543
543
|
});
|
|
544
544
|
|
|
@@ -562,7 +562,7 @@ describe("buildWellKnown", () => {
|
|
|
562
562
|
services: [mixed],
|
|
563
563
|
canonicalOrigin: "https://x.example",
|
|
564
564
|
});
|
|
565
|
-
const svc = doc.services.find((s) => s.name === "parachute-
|
|
565
|
+
const svc = doc.services.find((s) => s.name === "parachute-surface");
|
|
566
566
|
const full = svc?.uis?.find((u) => u.name === "full");
|
|
567
567
|
const minimal = svc?.uis?.find((u) => u.name === "minimal");
|
|
568
568
|
expect(full?.tagline).toBe("Has it all");
|
|
@@ -593,7 +593,7 @@ describe("buildWellKnown", () => {
|
|
|
593
593
|
services: [app1, app2],
|
|
594
594
|
canonicalOrigin: "https://x.example",
|
|
595
595
|
});
|
|
596
|
-
const svc1 = doc.services.find((s) => s.name === "parachute-
|
|
596
|
+
const svc1 = doc.services.find((s) => s.name === "parachute-surface");
|
|
597
597
|
const svc2 = doc.services.find((s) => s.name === "parachute-app-2");
|
|
598
598
|
expect(svc1?.uis?.map((u) => u.name)).toEqual(["a"]);
|
|
599
599
|
expect(svc2?.uis?.map((u) => u.name)).toEqual(["b"]);
|