@openparachute/hub 0.5.14-rc.12 → 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 -23
- package/src/__tests__/expose-2fa-warning.test.ts +27 -19
- package/src/__tests__/expose-auth-preflight.test.ts +47 -45
- package/src/__tests__/expose-cloudflare.test.ts +8 -8
- package/src/__tests__/expose.test.ts +3 -4
- 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/commands/auth.ts +146 -30
- package/src/commands/expose-2fa-warning.ts +25 -23
- package/src/commands/expose-auth-preflight.ts +50 -27
- package/src/commands/expose-cloudflare.ts +2 -2
- 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/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,26 +65,159 @@ async function captureOutput(fn: () => Promise<number> | number): Promise<{
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
describe("parachute auth", () => {
|
|
68
|
-
test("2fa is
|
|
69
|
-
// 2fa used to forward to the deprecated `parachute-vault 2fa` stub
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
86
|
});
|
|
81
87
|
|
|
82
|
-
test("2fa
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
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
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
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
|
+
}
|
|
88
221
|
});
|
|
89
222
|
|
|
90
223
|
test("set-password no longer forwards to vault", async () => {
|
|
@@ -138,12 +271,13 @@ describe("authHelp", () => {
|
|
|
138
271
|
expect(h).toContain("parachute auth rotate-key");
|
|
139
272
|
});
|
|
140
273
|
|
|
141
|
-
test("2fa help
|
|
274
|
+
test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
|
|
142
275
|
expect(h).toContain("#473");
|
|
143
|
-
//
|
|
144
|
-
expect(h).
|
|
145
|
-
expect(h).
|
|
146
|
-
expect(h).
|
|
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");
|
|
147
281
|
});
|
|
148
282
|
|
|
149
283
|
test("set-password help mentions the new flags + hub-local home", () => {
|
|
@@ -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,13 +87,14 @@ describe("printPublic2FAWarning", () => {
|
|
|
80
87
|
});
|
|
81
88
|
expect(fired).toBe(true);
|
|
82
89
|
const joined = logs.join("\n");
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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.
|
|
85
93
|
expect(joined).toContain("/login is now reachable on the public internet");
|
|
86
94
|
expect(joined).toContain("https://vault.example.com/login");
|
|
87
|
-
expect(joined).toContain("
|
|
95
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
96
|
+
expect(joined).toContain("/account/2fa");
|
|
88
97
|
expect(joined).toContain("parachute auth set-password");
|
|
89
|
-
expect(joined).not.toContain("parachute auth 2fa enroll");
|
|
90
98
|
});
|
|
91
99
|
|
|
92
100
|
test("enrolled → suppressed, returns false, logs nothing", () => {
|
|
@@ -41,8 +41,8 @@ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
44
|
-
test("warns loudly, offers password
|
|
45
|
-
const h = makeHarness(["y"]); // password yes
|
|
44
|
+
test("warns loudly, offers password + real 2FA enroll, prints hub-JWT token guidance", async () => {
|
|
45
|
+
const h = makeHarness(["y", "y"]); // password yes, 2FA yes
|
|
46
46
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
47
47
|
const joined = h.logs.join("\n");
|
|
48
48
|
expect(joined).toContain("No owner password");
|
|
@@ -50,42 +50,38 @@ describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
|
50
50
|
// Programmatic-client guidance points at the hub mint path, not pvt_*.
|
|
51
51
|
expect(joined).toContain("parachute auth mint-token --scope vault:default:read");
|
|
52
52
|
expect(joined).toContain("Bearer <hub-jwt>");
|
|
53
|
-
//
|
|
54
|
-
expect(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expect(h.
|
|
60
|
-
expect(h.prompts).toHaveLength(1);
|
|
53
|
+
// Real 2FA (hub#473) — both commands offered + run.
|
|
54
|
+
expect(h.commands.map((c) => c.join(" "))).toEqual([
|
|
55
|
+
"parachute auth set-password",
|
|
56
|
+
"parachute auth 2fa enroll",
|
|
57
|
+
]);
|
|
58
|
+
// Two interactive offers now: password + 2FA. Token guidance is printed.
|
|
59
|
+
expect(h.prompts).toHaveLength(2);
|
|
61
60
|
});
|
|
62
61
|
|
|
63
62
|
test("token guidance uses the first discovered vault name", async () => {
|
|
64
|
-
const h = makeHarness(["n"]);
|
|
63
|
+
const h = makeHarness(["n", "n"]);
|
|
65
64
|
await runAuthPreflight({ status: status({ vaultNames: ["work"] }), ...wire(h) });
|
|
66
65
|
expect(h.logs.join("\n")).toContain("--scope vault:work:read");
|
|
67
66
|
});
|
|
68
67
|
|
|
69
|
-
test("user declines
|
|
70
|
-
const h = makeHarness([""]); // Enter = skip
|
|
68
|
+
test("user declines both prompts → no subprocesses run", async () => {
|
|
69
|
+
const h = makeHarness(["", ""]); // Enter = skip both
|
|
71
70
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
72
71
|
expect(h.commands).toHaveLength(0);
|
|
73
|
-
|
|
74
|
-
expect(h.prompts).toHaveLength(1);
|
|
72
|
+
expect(h.prompts).toHaveLength(2);
|
|
75
73
|
});
|
|
76
74
|
|
|
77
75
|
test("user accepts the password offer → set-password invoked", async () => {
|
|
78
|
-
const h = makeHarness(["y"]);
|
|
76
|
+
const h = makeHarness(["y", "n"]);
|
|
79
77
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
80
78
|
expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth set-password"]);
|
|
81
79
|
});
|
|
82
80
|
|
|
83
81
|
test("null tokenCount with no owner password still classifies wide-open", async () => {
|
|
84
82
|
// The `tokenCount: null` (unreadable vault DB) path is vestigial post-DROP
|
|
85
|
-
// — `classify()` gates on `hasOwnerPassword` alone.
|
|
86
|
-
|
|
87
|
-
// branch, not silently fall through to a quieter state.
|
|
88
|
-
const h = makeHarness(["n"]);
|
|
83
|
+
// — `classify()` gates on `hasOwnerPassword` alone.
|
|
84
|
+
const h = makeHarness(["n", "n"]);
|
|
89
85
|
await runAuthPreflight({
|
|
90
86
|
status: status({ hasOwnerPassword: false, tokenCount: null }),
|
|
91
87
|
...wire(h),
|
|
@@ -93,58 +89,64 @@ describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
|
93
89
|
const joined = h.logs.join("\n");
|
|
94
90
|
expect(joined).toContain("No owner password");
|
|
95
91
|
expect(joined).toContain("public internet");
|
|
96
|
-
|
|
97
|
-
expect(h.prompts).toHaveLength(1);
|
|
92
|
+
expect(h.prompts).toHaveLength(2);
|
|
98
93
|
});
|
|
99
94
|
|
|
100
|
-
test("never offers
|
|
101
|
-
const h = makeHarness(["y"]);
|
|
95
|
+
test("never offers the dead vault-tokens-create command", async () => {
|
|
96
|
+
const h = makeHarness(["y", "n"]);
|
|
102
97
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
103
98
|
const allCommands = h.commands.map((c) => c.join(" ")).join("\n");
|
|
104
99
|
expect(allCommands).not.toContain("vault tokens create");
|
|
105
|
-
expect(allCommands).not.toContain("auth 2fa enroll");
|
|
106
|
-
// And no log line steers the operator at a dead command as guidance.
|
|
107
100
|
const guidance = h.logs.join("\n");
|
|
108
101
|
expect(guidance).not.toContain("parachute vault tokens create");
|
|
109
|
-
expect(guidance).not.toContain("parachute auth 2fa enroll");
|
|
110
102
|
});
|
|
111
103
|
});
|
|
112
104
|
|
|
113
|
-
describe("runAuthPreflight — password set", () => {
|
|
114
|
-
test("
|
|
115
|
-
const h = makeHarness([]);
|
|
105
|
+
describe("runAuthPreflight — password set, no 2FA", () => {
|
|
106
|
+
test("confirms password set + offers real 2FA enroll (ignores vestigial tokenCount)", async () => {
|
|
107
|
+
const h = makeHarness(["n"]); // decline 2FA
|
|
116
108
|
await runAuthPreflight({
|
|
117
109
|
// tokenCount is non-zero (vestigial pvt_* rows) but no longer consulted.
|
|
118
|
-
status: status({ hasOwnerPassword: true, tokenCount: 3 }),
|
|
110
|
+
status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: 3 }),
|
|
119
111
|
...wire(h),
|
|
120
112
|
});
|
|
121
113
|
const joined = h.logs.join("\n");
|
|
122
114
|
expect(joined).toContain("Owner password is set");
|
|
123
|
-
//
|
|
124
|
-
expect(
|
|
125
|
-
expect(
|
|
126
|
-
|
|
127
|
-
|
|
115
|
+
// Real 2FA offer (hub#473) — exactly one prompt (the 2FA offer).
|
|
116
|
+
expect(h.prompts).toHaveLength(1);
|
|
117
|
+
expect(h.commands).toHaveLength(0); // declined
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("accepting the 2FA offer runs `parachute auth 2fa enroll`", async () => {
|
|
121
|
+
const h = makeHarness(["y"]);
|
|
122
|
+
await runAuthPreflight({
|
|
123
|
+
status: status({ hasOwnerPassword: true, hasTotp: false }),
|
|
124
|
+
...wire(h),
|
|
125
|
+
});
|
|
126
|
+
expect(h.commands.map((c) => c.join(" "))).toEqual(["parachute auth 2fa enroll"]);
|
|
128
127
|
});
|
|
129
128
|
|
|
130
129
|
test("null tokenCount (DB unreadable) is irrelevant — password gates the branch", async () => {
|
|
131
|
-
const h = makeHarness([]);
|
|
130
|
+
const h = makeHarness(["n"]);
|
|
132
131
|
await runAuthPreflight({
|
|
133
132
|
status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
|
|
134
133
|
...wire(h),
|
|
135
134
|
});
|
|
136
135
|
expect(h.logs.join("\n")).toContain("Owner password is set");
|
|
137
|
-
expect(h.prompts).toHaveLength(
|
|
138
|
-
expect(h.commands).toHaveLength(0);
|
|
136
|
+
expect(h.prompts).toHaveLength(1);
|
|
139
137
|
});
|
|
138
|
+
});
|
|
140
139
|
|
|
141
|
-
|
|
140
|
+
describe("runAuthPreflight — password + 2FA both set", () => {
|
|
141
|
+
test("two-line confirmation, no prompts", async () => {
|
|
142
142
|
const h = makeHarness([]);
|
|
143
143
|
await runAuthPreflight({
|
|
144
144
|
status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 0 }),
|
|
145
145
|
...wire(h),
|
|
146
146
|
});
|
|
147
|
-
|
|
147
|
+
const joined = h.logs.join("\n");
|
|
148
|
+
expect(joined).toContain("Owner password is set");
|
|
149
|
+
expect(joined).toContain("Two-factor authentication is on");
|
|
148
150
|
expect(h.prompts).toHaveLength(0);
|
|
149
151
|
expect(h.commands).toHaveLength(0);
|
|
150
152
|
});
|
|
@@ -152,7 +154,7 @@ describe("runAuthPreflight — password set", () => {
|
|
|
152
154
|
|
|
153
155
|
describe("runAuthPreflight — subprocess failure handling", () => {
|
|
154
156
|
test("non-zero exit from set-password doesn't abort the rest of the preflight", async () => {
|
|
155
|
-
const h = makeHarness(["y"]);
|
|
157
|
+
const h = makeHarness(["y", "n"]);
|
|
156
158
|
const interactiveRunner = async (cmd: readonly string[]) => {
|
|
157
159
|
h.commands.push([...cmd]);
|
|
158
160
|
return 7;
|
|
@@ -177,11 +179,11 @@ describe("runAuthPreflight — subprocess failure handling", () => {
|
|
|
177
179
|
|
|
178
180
|
describe("runAuthPreflight — case-insensitive yes", () => {
|
|
179
181
|
test('"Y", "YES", and "y" all count as affirmative; anything else is decline', async () => {
|
|
180
|
-
//
|
|
182
|
+
// Drive the password-set-no-2FA path so there's exactly one prompt (2FA).
|
|
181
183
|
for (const yes of ["y", "Y", "yes", "YES"]) {
|
|
182
184
|
const h = makeHarness([yes]);
|
|
183
185
|
await runAuthPreflight({
|
|
184
|
-
status: status({ hasOwnerPassword: false }),
|
|
186
|
+
status: status({ hasOwnerPassword: true, hasTotp: false }),
|
|
185
187
|
...wire(h),
|
|
186
188
|
});
|
|
187
189
|
expect(h.commands).toHaveLength(1);
|
|
@@ -189,7 +191,7 @@ describe("runAuthPreflight — case-insensitive yes", () => {
|
|
|
189
191
|
for (const no of ["", "n", "no", "q", "bogus"]) {
|
|
190
192
|
const h = makeHarness([no]);
|
|
191
193
|
await runAuthPreflight({
|
|
192
|
-
status: status({ hasOwnerPassword: false }),
|
|
194
|
+
status: status({ hasOwnerPassword: true, hasTotp: false }),
|
|
193
195
|
...wire(h),
|
|
194
196
|
});
|
|
195
197
|
expect(h.commands).toHaveLength(0);
|
|
@@ -683,12 +683,11 @@ describe("exposeCloudflareUp", () => {
|
|
|
683
683
|
|
|
684
684
|
expect(code).toBe(0);
|
|
685
685
|
const joined = logs.join("\n");
|
|
686
|
-
//
|
|
687
|
-
//
|
|
686
|
+
// hub#473: real hub-login 2FA — the warning now recommends the real
|
|
687
|
+
// `parachute auth 2fa enroll` path.
|
|
688
688
|
expect(joined).toContain("/login is now reachable on the public internet");
|
|
689
689
|
expect(joined).toContain("https://vault.example.com/login");
|
|
690
|
-
expect(joined).toContain("
|
|
691
|
-
expect(joined).not.toContain("parachute auth 2fa enroll");
|
|
690
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
692
691
|
} finally {
|
|
693
692
|
env.cleanup();
|
|
694
693
|
}
|
|
@@ -736,11 +735,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
736
735
|
expect(code).toBe(0);
|
|
737
736
|
const joined = logs.join("\n");
|
|
738
737
|
expect(joined).not.toContain("/login is now reachable on the public internet");
|
|
739
|
-
// The contextual 2FA warning is suppressed (
|
|
740
|
-
//
|
|
741
|
-
//
|
|
738
|
+
// The contextual 2FA warning is suppressed (2FA already enrolled); the
|
|
739
|
+
// always-shown owner-password guidance from `printAuthGuidance` still
|
|
740
|
+
// appears, and it now (hub#473) also surfaces the real `2fa enroll`
|
|
741
|
+
// path in the humans section.
|
|
742
742
|
expect(joined).toContain("parachute auth set-password");
|
|
743
|
-
expect(joined).
|
|
743
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
744
744
|
} finally {
|
|
745
745
|
env.cleanup();
|
|
746
746
|
}
|
|
@@ -1009,11 +1009,10 @@ describe("expose public up", () => {
|
|
|
1009
1009
|
});
|
|
1010
1010
|
expect(code).toBe(0);
|
|
1011
1011
|
const joined = logs.join("\n");
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1012
|
+
// hub#473: real hub-login 2FA. The warning now recommends the real
|
|
1013
|
+
// `parachute auth 2fa enroll` path.
|
|
1014
1014
|
expect(joined).toContain("/login is now reachable on the public internet");
|
|
1015
|
-
expect(joined).toContain("
|
|
1016
|
-
expect(joined).not.toContain("parachute auth 2fa enroll");
|
|
1015
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
1017
1016
|
// /login pointer uses the canonical https://<fqdn> origin.
|
|
1018
1017
|
expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
|
|
1019
1018
|
} finally {
|