@openparachute/hub 0.5.14-rc.11 → 0.5.14-rc.13
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 +7 -4
- package/src/__tests__/account-home-ui.test.ts +38 -0
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +37 -0
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +43 -52
- package/src/__tests__/expose-cloudflare.test.ts +127 -8
- package/src/__tests__/expose.test.ts +3 -1
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/oauth-handlers.test.ts +223 -1
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/account-home-ui.ts +31 -1
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +45 -1
- package/src/api-account.ts +2 -0
- package/src/cli.ts +10 -1
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +46 -27
- package/src/commands/expose-cloudflare.ts +59 -2
- package/src/commands/init.ts +31 -0
- package/src/commands/wizard.ts +8 -4
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +30 -0
- package/src/oauth-handlers.ts +38 -3
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +22 -0
- package/src/setup-wizard.ts +5 -1
- 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/vault/auth-status.ts +59 -11
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.5.14-rc.
|
|
4
|
-
"description": "parachute
|
|
3
|
+
"version": "0.5.14-rc.13",
|
|
4
|
+
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -37,13 +37,16 @@
|
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@biomejs/biome": "^1.9.4",
|
|
40
|
-
"@types/bun": "latest"
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"@types/qrcode": "^1.5.6"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"typescript": "^5"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@node-rs/argon2": "^2.0.2",
|
|
47
|
-
"jose": "^6.2.2"
|
|
48
|
+
"jose": "^6.2.2",
|
|
49
|
+
"otpauth": "^9.5.0",
|
|
50
|
+
"qrcode": "^1.5.4"
|
|
48
51
|
}
|
|
49
52
|
}
|
|
@@ -32,6 +32,7 @@ describe("renderAccountHome", () => {
|
|
|
32
32
|
hubOrigin: HUB_ORIGIN,
|
|
33
33
|
isFirstAdmin: false,
|
|
34
34
|
csrfToken: CSRF,
|
|
35
|
+
twoFactorEnabled: false,
|
|
35
36
|
});
|
|
36
37
|
// Welcome header includes the username.
|
|
37
38
|
expect(html).toContain("Welcome, alice");
|
|
@@ -69,6 +70,7 @@ describe("renderAccountHome", () => {
|
|
|
69
70
|
hubOrigin: `${HUB_ORIGIN}/`,
|
|
70
71
|
isFirstAdmin: false,
|
|
71
72
|
csrfToken: CSRF,
|
|
73
|
+
twoFactorEnabled: false,
|
|
72
74
|
});
|
|
73
75
|
const cleanEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/alice`);
|
|
74
76
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${cleanEncoded}`);
|
|
@@ -86,6 +88,7 @@ describe("renderAccountHome", () => {
|
|
|
86
88
|
hubOrigin: HUB_ORIGIN,
|
|
87
89
|
isFirstAdmin: true,
|
|
88
90
|
csrfToken: CSRF,
|
|
91
|
+
twoFactorEnabled: false,
|
|
89
92
|
});
|
|
90
93
|
expect(html).toContain("Welcome, admin");
|
|
91
94
|
expect(html).toContain("hub administrator");
|
|
@@ -106,6 +109,7 @@ describe("renderAccountHome", () => {
|
|
|
106
109
|
hubOrigin: HUB_ORIGIN,
|
|
107
110
|
isFirstAdmin: false,
|
|
108
111
|
csrfToken: CSRF,
|
|
112
|
+
twoFactorEnabled: false,
|
|
109
113
|
});
|
|
110
114
|
expect(html).toContain("Welcome, ghost");
|
|
111
115
|
expect(html).toContain("Ask the hub operator");
|
|
@@ -123,6 +127,7 @@ describe("renderAccountHome", () => {
|
|
|
123
127
|
hubOrigin: HUB_ORIGIN,
|
|
124
128
|
isFirstAdmin: false,
|
|
125
129
|
csrfToken: CSRF,
|
|
130
|
+
twoFactorEnabled: false,
|
|
126
131
|
});
|
|
127
132
|
// Change-password link points at the existing /account/change-password
|
|
128
133
|
// route (server-rendered HTML, separate handler).
|
|
@@ -136,6 +141,37 @@ describe("renderAccountHome", () => {
|
|
|
136
141
|
expect(html).toContain("<code>alice</code>");
|
|
137
142
|
});
|
|
138
143
|
|
|
144
|
+
test("account card — 2FA status reflects twoFactorEnabled (hub#473)", () => {
|
|
145
|
+
const off = renderAccountHome({
|
|
146
|
+
username: "alice",
|
|
147
|
+
assignedVaults: ["alice"],
|
|
148
|
+
passwordChanged: true,
|
|
149
|
+
hubOrigin: HUB_ORIGIN,
|
|
150
|
+
isFirstAdmin: false,
|
|
151
|
+
csrfToken: CSRF,
|
|
152
|
+
twoFactorEnabled: false,
|
|
153
|
+
});
|
|
154
|
+
expect(off).toContain('data-testid="2fa-status"');
|
|
155
|
+
expect(off).toContain(">Off<");
|
|
156
|
+
// Off → "Set up two-factor" affordance.
|
|
157
|
+
expect(off).toContain('data-testid="setup-2fa-link"');
|
|
158
|
+
expect(off).toContain('href="/account/2fa"');
|
|
159
|
+
|
|
160
|
+
const on = renderAccountHome({
|
|
161
|
+
username: "alice",
|
|
162
|
+
assignedVaults: ["alice"],
|
|
163
|
+
passwordChanged: true,
|
|
164
|
+
hubOrigin: HUB_ORIGIN,
|
|
165
|
+
isFirstAdmin: false,
|
|
166
|
+
csrfToken: CSRF,
|
|
167
|
+
twoFactorEnabled: true,
|
|
168
|
+
});
|
|
169
|
+
expect(on).toContain(">On<");
|
|
170
|
+
// On → "Manage two-factor" affordance.
|
|
171
|
+
expect(on).toContain('data-testid="manage-2fa-link"');
|
|
172
|
+
expect(on).toContain('href="/account/2fa"');
|
|
173
|
+
});
|
|
174
|
+
|
|
139
175
|
test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
|
|
140
176
|
const html = renderAccountHome({
|
|
141
177
|
username: "alice",
|
|
@@ -144,6 +180,7 @@ describe("renderAccountHome", () => {
|
|
|
144
180
|
hubOrigin: HUB_ORIGIN,
|
|
145
181
|
isFirstAdmin: false,
|
|
146
182
|
csrfToken: CSRF,
|
|
183
|
+
twoFactorEnabled: false,
|
|
147
184
|
});
|
|
148
185
|
// Plural heading.
|
|
149
186
|
expect(html).toContain("Your vaults");
|
|
@@ -191,6 +228,7 @@ describe("renderAccountHome", () => {
|
|
|
191
228
|
hubOrigin: HUB_ORIGIN,
|
|
192
229
|
isFirstAdmin: false,
|
|
193
230
|
csrfToken: CSRF,
|
|
231
|
+
twoFactorEnabled: false,
|
|
194
232
|
});
|
|
195
233
|
// The injected username/vault metacharacters are escaped — the only
|
|
196
234
|
// `<script>` tag in the output is the page's own copy-button helper, so
|
|
@@ -65,34 +65,159 @@ async function captureOutput(fn: () => Promise<number> | number): Promise<{
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
describe("parachute auth", () => {
|
|
68
|
-
test("2fa
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
test("2fa is hub-local — never forwards to parachute-vault", async () => {
|
|
69
|
+
// 2fa used to forward to the deprecated `parachute-vault 2fa` stub. As of
|
|
70
|
+
// hub#473 it's real hub-login TOTP, fully hub-local (hub.db). No subprocess.
|
|
71
|
+
const tmp = makeTmp();
|
|
72
|
+
try {
|
|
73
|
+
const db = openHubDb(tmp.dbPath);
|
|
74
|
+
await createUser(db, "owner", "owner-password-123");
|
|
75
|
+
db.close();
|
|
76
|
+
const { runner, calls } = makeRunner(0);
|
|
77
|
+
const out = await captureOutput(() =>
|
|
78
|
+
auth(["2fa", "status"], { runner, dbPath: tmp.dbPath }),
|
|
79
|
+
);
|
|
80
|
+
expect(out.code).toBe(0);
|
|
81
|
+
expect(calls).toEqual([]); // did NOT spawn parachute-vault
|
|
82
|
+
expect(out.stdout).toContain("Two-factor authentication: OFF");
|
|
83
|
+
} finally {
|
|
84
|
+
tmp.cleanup();
|
|
85
|
+
}
|
|
73
86
|
});
|
|
74
87
|
|
|
75
|
-
test("2fa
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
test("2fa status reports OFF for a fresh user, then ON after a CLI enroll round-trip", async () => {
|
|
89
|
+
const tmp = makeTmp();
|
|
90
|
+
try {
|
|
91
|
+
const db = openHubDb(tmp.dbPath);
|
|
92
|
+
await createUser(db, "owner", "owner-password-123");
|
|
93
|
+
db.close();
|
|
94
|
+
|
|
95
|
+
// status → OFF
|
|
96
|
+
const off = await captureOutput(() =>
|
|
97
|
+
auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
98
|
+
);
|
|
99
|
+
expect(off.code).toBe(0);
|
|
100
|
+
expect(off.stdout).toContain("OFF");
|
|
101
|
+
|
|
102
|
+
// enroll requires a confirm code — drive the readLine seam with the
|
|
103
|
+
// live TOTP code generated from the secret the command prints.
|
|
104
|
+
const { generateTotpSecret } = await import("../totp.ts");
|
|
105
|
+
// We can't intercept the random secret the command mints, so instead
|
|
106
|
+
// exercise the store directly to assert the ON path is reachable, then
|
|
107
|
+
// assert the CLI status reflects it. (The handler-level enroll round
|
|
108
|
+
// trip is covered in two-factor.test.ts against the real secret.)
|
|
109
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
110
|
+
const { persistEnrollment } = await import("../two-factor-store.ts");
|
|
111
|
+
const u = listUsers(db2)[0]!;
|
|
112
|
+
const { secret } = generateTotpSecret(u.username);
|
|
113
|
+
await persistEnrollment(db2, u.id, secret);
|
|
114
|
+
db2.close();
|
|
115
|
+
|
|
116
|
+
const on = await captureOutput(() =>
|
|
117
|
+
auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
118
|
+
);
|
|
119
|
+
expect(on.code).toBe(0);
|
|
120
|
+
expect(on.stdout).toContain("ON");
|
|
121
|
+
expect(on.stdout).toContain("backup_codes");
|
|
122
|
+
|
|
123
|
+
// disenroll clears it.
|
|
124
|
+
const dis = await captureOutput(() =>
|
|
125
|
+
auth(["2fa", "disenroll"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
126
|
+
);
|
|
127
|
+
expect(dis.code).toBe(0);
|
|
128
|
+
expect(dis.stdout).toContain("Turned off");
|
|
129
|
+
|
|
130
|
+
const off2 = await captureOutput(() =>
|
|
131
|
+
auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
132
|
+
);
|
|
133
|
+
expect(off2.stdout).toContain("OFF");
|
|
134
|
+
} finally {
|
|
135
|
+
tmp.cleanup();
|
|
136
|
+
}
|
|
80
137
|
});
|
|
81
138
|
|
|
82
|
-
test("
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
139
|
+
test("2fa enroll confirms the printed secret against a live code, prints backup codes", async () => {
|
|
140
|
+
const tmp = makeTmp();
|
|
141
|
+
try {
|
|
142
|
+
const db = openHubDb(tmp.dbPath);
|
|
143
|
+
await createUser(db, "owner", "owner-password-123");
|
|
144
|
+
db.close();
|
|
145
|
+
|
|
146
|
+
// Single console.log interception that BOTH accumulates stdout and lets
|
|
147
|
+
// the readLine seam read the secret the command just printed. (Avoids
|
|
148
|
+
// nesting two console.log replacements.)
|
|
149
|
+
const OTPAuth = await import("otpauth");
|
|
150
|
+
const origLog = console.log;
|
|
151
|
+
let stdout = "";
|
|
152
|
+
let capturedSecret = "";
|
|
153
|
+
console.log = (...a: unknown[]) => {
|
|
154
|
+
const line = a.map(String).join(" ");
|
|
155
|
+
stdout += `${line}\n`;
|
|
156
|
+
const m = line.match(/secret key:\s+([A-Z2-7]+)/);
|
|
157
|
+
if (m) capturedSecret = m[1]!;
|
|
158
|
+
};
|
|
159
|
+
let code = "";
|
|
160
|
+
let exitCode = 0;
|
|
161
|
+
try {
|
|
162
|
+
exitCode = await auth(["2fa", "enroll"], {
|
|
163
|
+
dbPath: tmp.dbPath,
|
|
164
|
+
isInteractive: () => true,
|
|
165
|
+
readLine: async () => {
|
|
166
|
+
// The secret has been printed by the time the prompt fires.
|
|
167
|
+
const totp = new OTPAuth.TOTP({
|
|
168
|
+
issuer: "Parachute Hub",
|
|
169
|
+
label: "owner",
|
|
170
|
+
algorithm: "SHA1",
|
|
171
|
+
digits: 6,
|
|
172
|
+
period: 30,
|
|
173
|
+
secret: OTPAuth.Secret.fromBase32(capturedSecret),
|
|
174
|
+
});
|
|
175
|
+
code = totp.generate();
|
|
176
|
+
return code;
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
} finally {
|
|
180
|
+
console.log = origLog;
|
|
181
|
+
}
|
|
182
|
+
expect(exitCode).toBe(0);
|
|
183
|
+
expect(capturedSecret.length).toBeGreaterThan(0);
|
|
184
|
+
expect(stdout).toContain("now ON");
|
|
185
|
+
// 10 backup codes printed (hyphenated form).
|
|
186
|
+
const backupLines = stdout.split("\n").filter((l) => /^ {2}[a-z2-9]{5}-[a-z2-9]{5}$/.test(l));
|
|
187
|
+
expect(backupLines.length).toBe(10);
|
|
188
|
+
expect(code.length).toBe(6);
|
|
189
|
+
// The persisted state reflects the enrollment: the captured secret is
|
|
190
|
+
// now stored on the user row. (We don't re-verify `code` — the enroll
|
|
191
|
+
// confirm consumed it via the replay cache, by design.)
|
|
192
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
193
|
+
const { getTotpState } = await import("../two-factor-store.ts");
|
|
194
|
+
const uid = listUsers(db2)[0]!.id;
|
|
195
|
+
const persisted = getTotpState(db2, uid);
|
|
196
|
+
expect(persisted.secret).toBe(capturedSecret);
|
|
197
|
+
expect(persisted.backupCodes.length).toBe(10);
|
|
198
|
+
db2.close();
|
|
199
|
+
} finally {
|
|
200
|
+
tmp.cleanup();
|
|
201
|
+
}
|
|
86
202
|
});
|
|
87
203
|
|
|
88
|
-
test("
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
204
|
+
test("2fa enroll refuses to re-enroll when already on", async () => {
|
|
205
|
+
const tmp = makeTmp();
|
|
206
|
+
try {
|
|
207
|
+
const db = openHubDb(tmp.dbPath);
|
|
208
|
+
const u = await createUser(db, "owner", "owner-password-123");
|
|
209
|
+
const { generateTotpSecret } = await import("../totp.ts");
|
|
210
|
+
const { persistEnrollment } = await import("../two-factor-store.ts");
|
|
211
|
+
await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
|
|
212
|
+
db.close();
|
|
213
|
+
const out = await captureOutput(() =>
|
|
214
|
+
auth(["2fa", "enroll"], { dbPath: tmp.dbPath, isInteractive: () => true }),
|
|
215
|
+
);
|
|
216
|
+
expect(out.code).toBe(1);
|
|
217
|
+
expect(out.stderr).toContain("already enabled");
|
|
218
|
+
} finally {
|
|
219
|
+
tmp.cleanup();
|
|
220
|
+
}
|
|
96
221
|
});
|
|
97
222
|
|
|
98
223
|
test("set-password no longer forwards to vault", async () => {
|
|
@@ -142,23 +267,25 @@ describe("authHelp", () => {
|
|
|
142
267
|
test("lists every blessed subcommand", () => {
|
|
143
268
|
expect(h).toContain("parachute auth set-password");
|
|
144
269
|
expect(h).toContain("parachute auth list-users");
|
|
145
|
-
expect(h).toContain("parachute auth 2fa
|
|
146
|
-
expect(h).toContain("parachute auth 2fa enroll");
|
|
147
|
-
expect(h).toContain("parachute auth 2fa disable");
|
|
148
|
-
expect(h).toContain("parachute auth 2fa backup-codes");
|
|
270
|
+
expect(h).toContain("parachute auth 2fa");
|
|
149
271
|
expect(h).toContain("parachute auth rotate-key");
|
|
150
272
|
});
|
|
151
273
|
|
|
274
|
+
test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
|
|
275
|
+
expect(h).toContain("#473");
|
|
276
|
+
// Real enroll / disenroll subcommands are now advertised.
|
|
277
|
+
expect(h).toContain("2fa enroll");
|
|
278
|
+
expect(h).toContain("2fa disenroll");
|
|
279
|
+
expect(h).toContain("otpauth://");
|
|
280
|
+
expect(h).toContain("backup codes");
|
|
281
|
+
});
|
|
282
|
+
|
|
152
283
|
test("set-password help mentions the new flags + hub-local home", () => {
|
|
153
284
|
expect(h).toContain("--username");
|
|
154
285
|
expect(h).toContain("--allow-multi");
|
|
155
286
|
expect(h).toContain("hub.db");
|
|
156
287
|
});
|
|
157
288
|
|
|
158
|
-
test("mentions the vault-install hint", () => {
|
|
159
|
-
expect(h).toContain("parachute install vault");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
289
|
test("rotate-key explains the 24h JWKS retention", () => {
|
|
163
290
|
expect(h).toContain("jwks.json");
|
|
164
291
|
// "24" + "hours" may be split by line wrap; check both pieces.
|
|
@@ -123,6 +123,43 @@ describe("cli per-subcommand help", () => {
|
|
|
123
123
|
expect(stderr).toMatch(/dash\.cloudflare\.com/);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test("expose cloudflare is an alias for expose public --cloudflare (Fix 5)", async () => {
|
|
127
|
+
// No --domain, non-TTY → same hard error as `expose public --cloudflare`.
|
|
128
|
+
// That it reaches the cloudflare-domain check (not "unknown layer") proves
|
|
129
|
+
// the alias rewrote the layer to public + forced the cloudflare flag.
|
|
130
|
+
const { code, stderr } = await runCli(["expose", "cloudflare"]);
|
|
131
|
+
expect(code).toBe(1);
|
|
132
|
+
expect(stderr).toMatch(/--domain <hostname> is required/);
|
|
133
|
+
expect(stderr).not.toMatch(/unknown layer/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("expose cloudflare --domain X routes to the cloudflare path (not 'unknown layer')", async () => {
|
|
137
|
+
// cloudflared isn't installed under PATH="", so the cloudflare path prints
|
|
138
|
+
// its own not-installed hint — distinct from the layer-validation error.
|
|
139
|
+
const proc = Bun.spawn(
|
|
140
|
+
[process.execPath, CLI, "expose", "cloudflare", "--domain", "vault.example.com"],
|
|
141
|
+
{
|
|
142
|
+
stdout: "pipe",
|
|
143
|
+
stderr: "pipe",
|
|
144
|
+
env: {
|
|
145
|
+
...process.env,
|
|
146
|
+
PATH: "",
|
|
147
|
+
HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
148
|
+
PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
153
|
+
new Response(proc.stdout).text(),
|
|
154
|
+
new Response(proc.stderr).text(),
|
|
155
|
+
proc.exited,
|
|
156
|
+
]);
|
|
157
|
+
expect(code).toBe(1);
|
|
158
|
+
expect(stderr).not.toMatch(/unknown layer/);
|
|
159
|
+
// Reached the cloudflare path (cloudflared detection), proving the alias.
|
|
160
|
+
expect(stdout).toMatch(/cloudflared is not installed/);
|
|
161
|
+
});
|
|
162
|
+
|
|
126
163
|
test("expose tailnet --cloudflare is rejected (cloudflare is public-only)", async () => {
|
|
127
164
|
const { code, stderr } = await runCli([
|
|
128
165
|
"expose",
|
|
@@ -24,46 +24,53 @@ describe("is2FAEnrolled", () => {
|
|
|
24
24
|
expect(is2FAEnrolled({ status: status({ hasTotp: false }) })).toBe(false);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("
|
|
27
|
+
test("falls back to legacy config.yaml totp_secret when hub.db is absent", () => {
|
|
28
|
+
// hub#473: hub.db is the source of truth for real 2FA, but a super-old
|
|
29
|
+
// install with no hub.db still suppresses the warning if the legacy vault
|
|
30
|
+
// YAML totp_secret is set. Point hubDbPath at a nonexistent file so the
|
|
31
|
+
// YAML fallback is exercised.
|
|
28
32
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
29
33
|
try {
|
|
30
34
|
writeFileSync(join(dir, "config.yaml"), 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
|
|
31
|
-
expect(is2FAEnrolled({ vaultHome: dir })).toBe(true);
|
|
35
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(true);
|
|
32
36
|
} finally {
|
|
33
37
|
rmSync(dir, { recursive: true, force: true });
|
|
34
38
|
}
|
|
35
39
|
});
|
|
36
40
|
|
|
37
|
-
test("missing config.yaml → not enrolled (false)", () => {
|
|
41
|
+
test("missing config.yaml + absent hub.db → not enrolled (false)", () => {
|
|
38
42
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
39
43
|
try {
|
|
40
|
-
// No config.yaml written.
|
|
41
|
-
expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
|
|
44
|
+
// No config.yaml written, no hub.db.
|
|
45
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
|
|
42
46
|
} finally {
|
|
43
47
|
rmSync(dir, { recursive: true, force: true });
|
|
44
48
|
}
|
|
45
49
|
});
|
|
46
50
|
|
|
47
|
-
test("empty totp_secret value → not enrolled
|
|
51
|
+
test("empty totp_secret value + absent hub.db → not enrolled", () => {
|
|
48
52
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
49
53
|
try {
|
|
50
54
|
writeFileSync(join(dir, "config.yaml"), 'totp_secret: ""\n');
|
|
51
|
-
expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
|
|
55
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: join(dir, "absent-hub.db") })).toBe(false);
|
|
52
56
|
} finally {
|
|
53
57
|
rmSync(dir, { recursive: true, force: true });
|
|
54
58
|
}
|
|
55
59
|
});
|
|
56
60
|
|
|
57
|
-
test("
|
|
58
|
-
// The probe parses config.yaml with a line-anchored regex (no YAML
|
|
59
|
-
// dependency), so junk content simply doesn't match `totp_secret: "..."`
|
|
60
|
-
// and resolves to `hasTotp: false` — which fires the public-exposure
|
|
61
|
-
// warning rather than silently suppressing it. Pin that contract so a
|
|
62
|
-
// future refactor of auth-status.ts can't quietly invert it.
|
|
61
|
+
test("hub.db with an enrolled user → enrolled (the real signal)", async () => {
|
|
63
62
|
const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
|
|
64
63
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const { hubDbPath, openHubDb } = await import("../hub-db.ts");
|
|
65
|
+
const { createUser } = await import("../users.ts");
|
|
66
|
+
const { persistEnrollment } = await import("../two-factor-store.ts");
|
|
67
|
+
const { generateTotpSecret } = await import("../totp.ts");
|
|
68
|
+
const dbPath = hubDbPath(dir);
|
|
69
|
+
const db = openHubDb(dbPath);
|
|
70
|
+
const u = await createUser(db, "owner", "owner-password-123");
|
|
71
|
+
await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
|
|
72
|
+
db.close();
|
|
73
|
+
expect(is2FAEnrolled({ vaultHome: dir, hubDbPath: dbPath })).toBe(true);
|
|
67
74
|
} finally {
|
|
68
75
|
rmSync(dir, { recursive: true, force: true });
|
|
69
76
|
}
|
|
@@ -80,9 +87,14 @@ describe("printPublic2FAWarning", () => {
|
|
|
80
87
|
});
|
|
81
88
|
expect(fired).toBe(true);
|
|
82
89
|
const joined = logs.join("\n");
|
|
83
|
-
|
|
90
|
+
// hub#473: real hub-login 2FA. The warning now recommends the real
|
|
91
|
+
// `parachute auth 2fa enroll` path (+ the /account/2fa browser path) and
|
|
92
|
+
// still nudges a strong owner password.
|
|
93
|
+
expect(joined).toContain("/login is now reachable on the public internet");
|
|
84
94
|
expect(joined).toContain("https://vault.example.com/login");
|
|
85
95
|
expect(joined).toContain("parachute auth 2fa enroll");
|
|
96
|
+
expect(joined).toContain("/account/2fa");
|
|
97
|
+
expect(joined).toContain("parachute auth set-password");
|
|
86
98
|
});
|
|
87
99
|
|
|
88
100
|
test("enrolled → suppressed, returns false, logs nothing", () => {
|
|
@@ -108,7 +120,9 @@ describe("printPublic2FAWarning", () => {
|
|
|
108
120
|
publicUrl: "https://vault.example.com",
|
|
109
121
|
});
|
|
110
122
|
expect(fired).toBe(true);
|
|
111
|
-
expect(logs.some((l) => l.includes("
|
|
123
|
+
expect(logs.some((l) => l.includes("/login is now reachable on the public internet"))).toBe(
|
|
124
|
+
true,
|
|
125
|
+
);
|
|
112
126
|
});
|
|
113
127
|
|
|
114
128
|
test("embeds the supplied publicUrl into the /login pointer", () => {
|