@openparachute/hub 0.5.14-rc.8 → 0.6.0
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-ops.test.ts +45 -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__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- 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__/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 +335 -15
- 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 +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -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-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- 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 +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -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/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 +77 -7
- 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 +71 -19
- 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
|
+
});
|
|
@@ -39,10 +39,10 @@ describe("readVaultAuthStatus — hub.db source of truth (multi-user Phase 1+)",
|
|
|
39
39
|
vaultHome: env.path,
|
|
40
40
|
countTokens: () => 0,
|
|
41
41
|
probeHubDbHasUserPassword: () => true,
|
|
42
|
+
probeHubDbHasTotp: () => false,
|
|
42
43
|
});
|
|
43
44
|
expect(status.hasOwnerPassword).toBe(true);
|
|
44
|
-
// No
|
|
45
|
-
// path until the schema gains a column.
|
|
45
|
+
// No TOTP enrolled in hub.db (and no legacy YAML) → false.
|
|
46
46
|
expect(status.hasTotp).toBe(false);
|
|
47
47
|
} finally {
|
|
48
48
|
env.cleanup();
|
|
@@ -57,6 +57,7 @@ describe("readVaultAuthStatus — hub.db source of truth (multi-user Phase 1+)",
|
|
|
57
57
|
vaultHome: env.path,
|
|
58
58
|
countTokens: () => 0,
|
|
59
59
|
probeHubDbHasUserPassword: () => false,
|
|
60
|
+
probeHubDbHasTotp: () => false,
|
|
60
61
|
});
|
|
61
62
|
expect(status.hasOwnerPassword).toBe(true);
|
|
62
63
|
expect(status.hasTotp).toBe(false);
|
|
@@ -87,6 +88,7 @@ describe("readVaultAuthStatus — hub.db source of truth (multi-user Phase 1+)",
|
|
|
87
88
|
vaultHome: env.path,
|
|
88
89
|
countTokens: () => 0,
|
|
89
90
|
probeHubDbHasUserPassword: () => false,
|
|
91
|
+
probeHubDbHasTotp: () => false,
|
|
90
92
|
});
|
|
91
93
|
expect(status.hasOwnerPassword).toBe(false);
|
|
92
94
|
expect(status.hasTotp).toBe(false);
|
|
@@ -95,17 +97,18 @@ describe("readVaultAuthStatus — hub.db source of truth (multi-user Phase 1+)",
|
|
|
95
97
|
}
|
|
96
98
|
});
|
|
97
99
|
|
|
98
|
-
test("hub.db
|
|
100
|
+
test("hub.db password=true, hub.db TOTP unreachable → TOTP falls back to YAML state", () => {
|
|
99
101
|
const env = makeVaultHome();
|
|
100
102
|
try {
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// totp=true (YAML).
|
|
103
|
+
// hub#473: hub.db is the canonical TOTP source, but when the TOTP probe
|
|
104
|
+
// is unreachable (pre-v11 column absent) it falls back to the legacy
|
|
105
|
+
// vault YAML totp_secret. password=true (hub.db), totp=true (YAML fallback).
|
|
104
106
|
writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
|
|
105
107
|
const status = readVaultAuthStatus({
|
|
106
108
|
vaultHome: env.path,
|
|
107
109
|
countTokens: () => 0,
|
|
108
110
|
probeHubDbHasUserPassword: () => true,
|
|
111
|
+
probeHubDbHasTotp: () => undefined,
|
|
109
112
|
});
|
|
110
113
|
expect(status.hasOwnerPassword).toBe(true);
|
|
111
114
|
expect(status.hasTotp).toBe(true);
|
|
@@ -113,6 +116,40 @@ describe("readVaultAuthStatus — hub.db source of truth (multi-user Phase 1+)",
|
|
|
113
116
|
env.cleanup();
|
|
114
117
|
}
|
|
115
118
|
});
|
|
119
|
+
|
|
120
|
+
test("hub.db TOTP=true is the real signal — overrides absent YAML", () => {
|
|
121
|
+
const env = makeVaultHome();
|
|
122
|
+
try {
|
|
123
|
+
// No YAML totp_secret, but a hub.db user has enrolled real 2FA (hub#473).
|
|
124
|
+
const status = readVaultAuthStatus({
|
|
125
|
+
vaultHome: env.path,
|
|
126
|
+
countTokens: () => 0,
|
|
127
|
+
probeHubDbHasUserPassword: () => true,
|
|
128
|
+
probeHubDbHasTotp: () => true,
|
|
129
|
+
});
|
|
130
|
+
expect(status.hasTotp).toBe(true);
|
|
131
|
+
} finally {
|
|
132
|
+
env.cleanup();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("hub.db TOTP=false (column present, none enrolled) overrides a stale YAML true", () => {
|
|
137
|
+
const env = makeVaultHome();
|
|
138
|
+
try {
|
|
139
|
+
// Legacy YAML totp_secret present, but hub.db definitively says no user
|
|
140
|
+
// has enrolled real hub-login 2FA → report false (the real signal wins).
|
|
141
|
+
writeConfig(env.path, 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
|
|
142
|
+
const status = readVaultAuthStatus({
|
|
143
|
+
vaultHome: env.path,
|
|
144
|
+
countTokens: () => 0,
|
|
145
|
+
probeHubDbHasUserPassword: () => true,
|
|
146
|
+
probeHubDbHasTotp: () => false,
|
|
147
|
+
});
|
|
148
|
+
expect(status.hasTotp).toBe(false);
|
|
149
|
+
} finally {
|
|
150
|
+
env.cleanup();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
116
153
|
});
|
|
117
154
|
|
|
118
155
|
describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () => {
|
|
@@ -123,6 +160,7 @@ describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () =
|
|
|
123
160
|
vaultHome: env.path,
|
|
124
161
|
countTokens: () => 0,
|
|
125
162
|
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
163
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
126
164
|
});
|
|
127
165
|
expect(status.hasOwnerPassword).toBe(false);
|
|
128
166
|
expect(status.hasTotp).toBe(false);
|
|
@@ -147,6 +185,7 @@ describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () =
|
|
|
147
185
|
vaultHome: env.path,
|
|
148
186
|
countTokens: () => 0,
|
|
149
187
|
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
188
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
150
189
|
});
|
|
151
190
|
expect(status.hasOwnerPassword).toBe(true);
|
|
152
191
|
expect(status.hasTotp).toBe(true);
|
|
@@ -163,6 +202,7 @@ describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () =
|
|
|
163
202
|
vaultHome: env.path,
|
|
164
203
|
countTokens: () => 0,
|
|
165
204
|
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
205
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
166
206
|
});
|
|
167
207
|
expect(status.hasOwnerPassword).toBe(false);
|
|
168
208
|
expect(status.hasTotp).toBe(false);
|
|
@@ -179,6 +219,7 @@ describe("readVaultAuthStatus — YAML fallback (pre-multi-user installs)", () =
|
|
|
179
219
|
vaultHome: env.path,
|
|
180
220
|
countTokens: () => 0,
|
|
181
221
|
probeHubDbHasUserPassword: hubDbUnreachable,
|
|
222
|
+
probeHubDbHasTotp: hubDbUnreachable,
|
|
182
223
|
});
|
|
183
224
|
expect(status.hasOwnerPassword).toBe(true);
|
|
184
225
|
expect(status.hasTotp).toBe(false);
|