@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import * as OTPAuth from "otpauth";
|
|
7
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
8
|
+
import {
|
|
9
|
+
_resetTotpReplayCache,
|
|
10
|
+
findBackupCodeIndex,
|
|
11
|
+
generateBackupCodes,
|
|
12
|
+
generateTotpSecret,
|
|
13
|
+
normalizeBackupCode,
|
|
14
|
+
verifyTotpCode,
|
|
15
|
+
} from "../totp.ts";
|
|
16
|
+
import {
|
|
17
|
+
backupCodesRemaining,
|
|
18
|
+
clearEnrollment,
|
|
19
|
+
getTotpState,
|
|
20
|
+
isTotpEnrolled,
|
|
21
|
+
persistEnrollment,
|
|
22
|
+
verifySecondFactor,
|
|
23
|
+
} from "../two-factor-store.ts";
|
|
24
|
+
import { createUser } from "../users.ts";
|
|
25
|
+
|
|
26
|
+
/** Generate the current live TOTP code for a base32 secret. */
|
|
27
|
+
function liveCode(secretBase32: string, label = "owner"): string {
|
|
28
|
+
return new OTPAuth.TOTP({
|
|
29
|
+
issuer: "Parachute Hub",
|
|
30
|
+
label,
|
|
31
|
+
algorithm: "SHA1",
|
|
32
|
+
digits: 6,
|
|
33
|
+
period: 30,
|
|
34
|
+
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
|
35
|
+
}).generate();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("totp — secret + code", () => {
|
|
39
|
+
beforeEach(() => _resetTotpReplayCache());
|
|
40
|
+
|
|
41
|
+
test("generateTotpSecret returns a base32 secret + an otpauth:// URI", () => {
|
|
42
|
+
const { secret, otpauthUrl } = generateTotpSecret("alice");
|
|
43
|
+
expect(secret.length).toBeGreaterThan(0);
|
|
44
|
+
expect(/^[A-Z2-7]+$/.test(secret)).toBe(true);
|
|
45
|
+
expect(otpauthUrl.startsWith("otpauth://totp/")).toBe(true);
|
|
46
|
+
expect(otpauthUrl).toContain("Parachute%20Hub");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a live code verifies; a wrong code does not", () => {
|
|
50
|
+
const { secret } = generateTotpSecret("alice");
|
|
51
|
+
expect(verifyTotpCode(secret, liveCode(secret))).toBe(true);
|
|
52
|
+
_resetTotpReplayCache();
|
|
53
|
+
expect(verifyTotpCode(secret, "000000")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("non-6-digit input is rejected without throwing", () => {
|
|
57
|
+
const { secret } = generateTotpSecret("alice");
|
|
58
|
+
expect(verifyTotpCode(secret, "12345")).toBe(false);
|
|
59
|
+
expect(verifyTotpCode(secret, "1234567")).toBe(false);
|
|
60
|
+
expect(verifyTotpCode(secret, "abcdef")).toBe(false);
|
|
61
|
+
expect(verifyTotpCode(secret, "")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("replay: a code accepted once is rejected on the second try (markUsed default)", () => {
|
|
65
|
+
const { secret } = generateTotpSecret("alice");
|
|
66
|
+
const code = liveCode(secret);
|
|
67
|
+
expect(verifyTotpCode(secret, code)).toBe(true);
|
|
68
|
+
expect(verifyTotpCode(secret, code)).toBe(false); // replay rejected
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("totp — backup codes", () => {
|
|
73
|
+
test("generates 10 hyphenated codes + matching argon2id hashes", async () => {
|
|
74
|
+
const { codes, hashes } = await generateBackupCodes();
|
|
75
|
+
expect(codes.length).toBe(10);
|
|
76
|
+
expect(hashes.length).toBe(10);
|
|
77
|
+
for (const c of codes) {
|
|
78
|
+
expect(/^[a-z2-9]{5}-[a-z2-9]{5}$/.test(c)).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
for (const h of hashes) {
|
|
81
|
+
expect(h.startsWith("$argon2")).toBe(true);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("findBackupCodeIndex matches a code (hyphen-insensitive) and rejects unknowns", async () => {
|
|
86
|
+
const { codes, hashes } = await generateBackupCodes();
|
|
87
|
+
// Match by normalized form (strip hyphen) and by raw hyphenated form.
|
|
88
|
+
const idx0 = await findBackupCodeIndex(codes[0]!, hashes);
|
|
89
|
+
expect(idx0).toBe(0);
|
|
90
|
+
const idxNorm = await findBackupCodeIndex(normalizeBackupCode(codes[3]!), hashes);
|
|
91
|
+
expect(idxNorm).toBe(3);
|
|
92
|
+
const miss = await findBackupCodeIndex("zzzzz-zzzzz", hashes);
|
|
93
|
+
expect(miss).toBe(-1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("two-factor-store", () => {
|
|
98
|
+
let db: Database;
|
|
99
|
+
let configDir: string;
|
|
100
|
+
let userId: string;
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
_resetTotpReplayCache();
|
|
104
|
+
configDir = mkdtempSync(join(tmpdir(), "phub-2fa-store-"));
|
|
105
|
+
db = openHubDb(hubDbPath(configDir));
|
|
106
|
+
const u = await createUser(db, "owner", "owner-password-123");
|
|
107
|
+
userId = u.id;
|
|
108
|
+
});
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
db.close();
|
|
111
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("fresh user: not enrolled, no backup codes", () => {
|
|
115
|
+
expect(isTotpEnrolled(db, userId)).toBe(false);
|
|
116
|
+
expect(getTotpState(db, userId).secret).toBeNull();
|
|
117
|
+
expect(backupCodesRemaining(db, userId)).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("persistEnrollment stores the secret + 10 backup codes (hashed) + a timestamp", async () => {
|
|
121
|
+
const { secret } = generateTotpSecret("owner");
|
|
122
|
+
const result = await persistEnrollment(db, userId, secret);
|
|
123
|
+
expect(result.backupCodes.length).toBe(10);
|
|
124
|
+
expect(isTotpEnrolled(db, userId)).toBe(true);
|
|
125
|
+
const state = getTotpState(db, userId);
|
|
126
|
+
expect(state.secret).toBe(secret);
|
|
127
|
+
expect(state.backupCodes.length).toBe(10);
|
|
128
|
+
expect(state.enrolledAt).toBeTruthy();
|
|
129
|
+
// Stored codes are hashes, NOT the plaintext returned to the user.
|
|
130
|
+
for (const stored of state.backupCodes) {
|
|
131
|
+
expect(stored.startsWith("$argon2")).toBe(true);
|
|
132
|
+
expect(result.backupCodes).not.toContain(stored);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("verifySecondFactor accepts a live TOTP code", async () => {
|
|
137
|
+
const { secret } = generateTotpSecret("owner");
|
|
138
|
+
await persistEnrollment(db, userId, secret);
|
|
139
|
+
const res = await verifySecondFactor(db, userId, liveCode(secret), false);
|
|
140
|
+
expect(res).toEqual({ ok: true, via: "totp" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("verifySecondFactor accepts a backup code ONCE, then rejects its reuse (single-use)", async () => {
|
|
144
|
+
const { secret } = generateTotpSecret("owner");
|
|
145
|
+
const { backupCodes } = await persistEnrollment(db, userId, secret);
|
|
146
|
+
const code = backupCodes[0]!;
|
|
147
|
+
expect(backupCodesRemaining(db, userId)).toBe(10);
|
|
148
|
+
|
|
149
|
+
const first = await verifySecondFactor(db, userId, code, false);
|
|
150
|
+
expect(first).toEqual({ ok: true, via: "backup_code" });
|
|
151
|
+
// Consumed: one fewer remaining.
|
|
152
|
+
expect(backupCodesRemaining(db, userId)).toBe(9);
|
|
153
|
+
|
|
154
|
+
// Reuse of the same code is rejected.
|
|
155
|
+
const second = await verifySecondFactor(db, userId, code, false);
|
|
156
|
+
expect(second).toEqual({ ok: false });
|
|
157
|
+
expect(backupCodesRemaining(db, userId)).toBe(9);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("verifySecondFactor rejects a wrong code without consuming anything", async () => {
|
|
161
|
+
const { secret } = generateTotpSecret("owner");
|
|
162
|
+
await persistEnrollment(db, userId, secret);
|
|
163
|
+
const res = await verifySecondFactor(db, userId, "999999", false);
|
|
164
|
+
expect(res).toEqual({ ok: false });
|
|
165
|
+
expect(backupCodesRemaining(db, userId)).toBe(10);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("clearEnrollment removes secret + backup codes (idempotent)", async () => {
|
|
169
|
+
const { secret } = generateTotpSecret("owner");
|
|
170
|
+
await persistEnrollment(db, userId, secret);
|
|
171
|
+
expect(isTotpEnrolled(db, userId)).toBe(true);
|
|
172
|
+
clearEnrollment(db, userId);
|
|
173
|
+
expect(isTotpEnrolled(db, userId)).toBe(false);
|
|
174
|
+
expect(backupCodesRemaining(db, userId)).toBe(0);
|
|
175
|
+
// Idempotent — clearing again is a no-op.
|
|
176
|
+
clearEnrollment(db, userId);
|
|
177
|
+
expect(isTotpEnrolled(db, userId)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("verifySecondFactor on a not-enrolled user returns ok:false", async () => {
|
|
181
|
+
expect(await verifySecondFactor(db, userId, "123456", false)).toEqual({ ok: false });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { UpgradeRunner } from "../commands/upgrade.ts";
|
|
6
|
-
import { compareVersions, detectChannel, upgrade } from "../commands/upgrade.ts";
|
|
6
|
+
import { compareVersions, defaultRunner, detectChannel, upgrade } from "../commands/upgrade.ts";
|
|
7
7
|
import { upsertService } from "../services-manifest.ts";
|
|
8
8
|
|
|
9
9
|
interface RunCall {
|
|
@@ -402,6 +402,83 @@ describe("parachute upgrade", () => {
|
|
|
402
402
|
}
|
|
403
403
|
});
|
|
404
404
|
|
|
405
|
+
test("git absent (ENOENT): no crash, isGitCheckout → false, npm path taken", async () => {
|
|
406
|
+
// Real EC2 repro: a published-npm install on a minimal server with no
|
|
407
|
+
// `git` binary. The production runner's Bun.spawn(["git", ...]) throws
|
|
408
|
+
// synchronously with ENOENT; the upgrade flow must degrade to the npm
|
|
409
|
+
// path rather than crashing with an uncaught "Executable not found".
|
|
410
|
+
const h = makeHarness();
|
|
411
|
+
try {
|
|
412
|
+
const installDir = join(h.installRoot, "vault");
|
|
413
|
+
writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
|
|
414
|
+
seedVault(h.manifestPath, installDir, "0.4.0");
|
|
415
|
+
|
|
416
|
+
const calls: RunCall[] = [];
|
|
417
|
+
// Simulate the git-absent host: any spawn of `git` ENOENTs, surfaced
|
|
418
|
+
// through the runner as a non-zero captured result (code 127). This is
|
|
419
|
+
// exactly what the patched defaultRunner produces — we assert the
|
|
420
|
+
// upgrade flow handles that result gracefully.
|
|
421
|
+
const runner: UpgradeRunner = {
|
|
422
|
+
async run(cmd, opts) {
|
|
423
|
+
calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "run" });
|
|
424
|
+
if (cmd[0] === "git") return 127; // git-less host
|
|
425
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
426
|
+
writePackageJson(installDir, { name: "@openparachute/vault", version: "0.5.0" });
|
|
427
|
+
}
|
|
428
|
+
return 0;
|
|
429
|
+
},
|
|
430
|
+
async capture(cmd, opts) {
|
|
431
|
+
calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "capture" });
|
|
432
|
+
if (cmd[0] === "git") return { code: 127, stdout: "git: not found on this host\n" };
|
|
433
|
+
return { code: 0, stdout: "" };
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
let restartedShort: string | undefined;
|
|
438
|
+
const logs: string[] = [];
|
|
439
|
+
const code = await upgrade("vault", {
|
|
440
|
+
manifestPath: h.manifestPath,
|
|
441
|
+
configDir: h.configDir,
|
|
442
|
+
runner,
|
|
443
|
+
findGlobalInstall: () => join(installDir, "package.json"),
|
|
444
|
+
restartFn: async (svc) => {
|
|
445
|
+
restartedShort = svc;
|
|
446
|
+
return 0;
|
|
447
|
+
},
|
|
448
|
+
log: (l) => logs.push(l),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// No throw; the npm path ran end-to-end.
|
|
452
|
+
expect(code).toBe(0);
|
|
453
|
+
expect(restartedShort).toBe("vault");
|
|
454
|
+
const joined = logs.join("\n");
|
|
455
|
+
// isGitCheckout returned false → npm-installed branch, not bun-linked.
|
|
456
|
+
expect(joined).toMatch(/npm-installed/);
|
|
457
|
+
expect(joined).not.toMatch(/bun-linked/);
|
|
458
|
+
expect(joined).toMatch(/bun add -g @openparachute\/vault@latest/);
|
|
459
|
+
expect(joined).toMatch(/0\.4\.0 → 0\.5\.0/);
|
|
460
|
+
// We probed git (and degraded) but never reached the git-mutating
|
|
461
|
+
// commands (pull / status) that only run on the bun-linked branch.
|
|
462
|
+
const gitRun = calls.filter((c) => c.kind === "run" && c.cmd[0] === "git");
|
|
463
|
+
expect(gitRun).toHaveLength(0);
|
|
464
|
+
} finally {
|
|
465
|
+
h.cleanup();
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("defaultRunner.capture: git-absent ENOENT yields code 127, no throw", async () => {
|
|
470
|
+
// Drive the *production* runner against a binary that doesn't exist, to
|
|
471
|
+
// prove the synchronous-spawn-throw is caught (not just the injectable
|
|
472
|
+
// seam). Bun.spawn throws ENOENT synchronously for a missing binary.
|
|
473
|
+
const missing = `parachute-no-such-binary-${process.pid}`;
|
|
474
|
+
const captured = await defaultRunner.capture([missing, "--version"]);
|
|
475
|
+
expect(captured.code).toBe(127);
|
|
476
|
+
expect(captured.stdout).toContain("not found on this host");
|
|
477
|
+
|
|
478
|
+
const ran = await defaultRunner.run([missing, "--version"]);
|
|
479
|
+
expect(ran).toBe(127);
|
|
480
|
+
});
|
|
481
|
+
|
|
405
482
|
test("npm-installed: version unchanged → skip restart", async () => {
|
|
406
483
|
const h = makeHarness();
|
|
407
484
|
try {
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
userCount,
|
|
25
25
|
validatePassword,
|
|
26
26
|
validateUsername,
|
|
27
|
+
vaultVerbsForRole,
|
|
28
|
+
vaultVerbsForUserVault,
|
|
27
29
|
verifyPassword,
|
|
28
30
|
} from "../users.ts";
|
|
29
31
|
|
|
@@ -735,3 +737,69 @@ describe("getFirstAdminId / isFirstAdmin", () => {
|
|
|
735
737
|
}
|
|
736
738
|
});
|
|
737
739
|
});
|
|
740
|
+
|
|
741
|
+
describe("vaultVerbsForRole / vaultVerbsForUserVault (friend token-mint cap)", () => {
|
|
742
|
+
test("vaultVerbsForRole maps roles to verbs, fails closed on unknown", () => {
|
|
743
|
+
expect(vaultVerbsForRole("write")).toEqual(["read", "write"]);
|
|
744
|
+
expect(vaultVerbsForRole("read")).toEqual(["read"]);
|
|
745
|
+
// Unknown / future roles grant NO mintable verb — never silently
|
|
746
|
+
// default to write. `admin` is explicitly NOT a mintable role here.
|
|
747
|
+
expect(vaultVerbsForRole("admin")).toEqual([]);
|
|
748
|
+
expect(vaultVerbsForRole("owner")).toEqual([]);
|
|
749
|
+
expect(vaultVerbsForRole("")).toEqual([]);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("vaultVerbsForUserVault returns the role's verbs for an assigned vault", async () => {
|
|
753
|
+
const { db, cleanup } = makeDb();
|
|
754
|
+
try {
|
|
755
|
+
await createUser(db, "admin", "pw1");
|
|
756
|
+
const friend = await createUser(db, "alice", "pw2", {
|
|
757
|
+
allowMulti: true,
|
|
758
|
+
assignedVaults: ["work"],
|
|
759
|
+
});
|
|
760
|
+
// createUser/setUserVaults insert role='write' today → read+write.
|
|
761
|
+
expect(vaultVerbsForUserVault(db, friend.id, "work")).toEqual(["read", "write"]);
|
|
762
|
+
} finally {
|
|
763
|
+
cleanup();
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("vaultVerbsForUserVault returns null for a vault NOT in the assignment", async () => {
|
|
768
|
+
// This null is the authorization spine of the friend mint path: a vault
|
|
769
|
+
// the user isn't assigned to → null → the handler 403s. No cross-vault.
|
|
770
|
+
const { db, cleanup } = makeDb();
|
|
771
|
+
try {
|
|
772
|
+
await createUser(db, "admin", "pw1");
|
|
773
|
+
const friend = await createUser(db, "alice", "pw2", {
|
|
774
|
+
allowMulti: true,
|
|
775
|
+
assignedVaults: ["work"],
|
|
776
|
+
});
|
|
777
|
+
expect(vaultVerbsForUserVault(db, friend.id, "other")).toBeNull();
|
|
778
|
+
// The unrestricted admin has no user_vaults rows → null for everything.
|
|
779
|
+
const admin = getUserById(db, getFirstAdminId(db) ?? "");
|
|
780
|
+
expect(vaultVerbsForUserVault(db, admin?.id ?? "", "work")).toBeNull();
|
|
781
|
+
} finally {
|
|
782
|
+
cleanup();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("vaultVerbsForUserVault honors a hand-set read-only role (fail-closed forward-compat)", async () => {
|
|
787
|
+
// The schema reserves `role` for future granularity; if a row ever holds
|
|
788
|
+
// role='read', the cap must drop write. Simulate by direct UPDATE.
|
|
789
|
+
const { db, cleanup } = makeDb();
|
|
790
|
+
try {
|
|
791
|
+
await createUser(db, "admin", "pw1");
|
|
792
|
+
const friend = await createUser(db, "alice", "pw2", {
|
|
793
|
+
allowMulti: true,
|
|
794
|
+
assignedVaults: ["work"],
|
|
795
|
+
});
|
|
796
|
+
db.prepare("UPDATE user_vaults SET role = 'read' WHERE user_id = ? AND vault_name = ?").run(
|
|
797
|
+
friend.id,
|
|
798
|
+
"work",
|
|
799
|
+
);
|
|
800
|
+
expect(vaultVerbsForUserVault(db, friend.id, "work")).toEqual(["read"]);
|
|
801
|
+
} finally {
|
|
802
|
+
cleanup();
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
});
|