@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20
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 +4 -11
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +52 -25
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/api-modules.ts +105 -0
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +139 -24
- package/src/hub-settings.ts +163 -1
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +103 -6
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/account/*` JSON self-service endpoints (hub#85): password change +
|
|
3
|
+
* 2FA start/confirm/disable. Plus `/api/me`'s `two_factor_enabled` field.
|
|
4
|
+
*
|
|
5
|
+
* Coverage:
|
|
6
|
+
* - auth: no session → 401; wrong CSRF → 403; self-only (keyed off session)
|
|
7
|
+
* - password: happy path (hash rotated + tokens revoked); wrong current →
|
|
8
|
+
* 401; too short → 400; mismatch handled client-side (not here); new ===
|
|
9
|
+
* current → 400; too long → 413
|
|
10
|
+
* - 2fa start → secret + qr; already-enrolled → 409
|
|
11
|
+
* - 2fa confirm: round-trip with a live code persists + returns backup codes;
|
|
12
|
+
* bad code → 400; malformed secret → 400
|
|
13
|
+
* - 2fa disable: password-gated (wrong → 401), clears enrollment; idempotent
|
|
14
|
+
* - /api/me reflects two_factor_enabled
|
|
15
|
+
*/
|
|
16
|
+
import type { Database } from "bun:sqlite";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import * as OTPAuth from "otpauth";
|
|
22
|
+
import { handleApiAccount } from "../api-account-2fa.ts";
|
|
23
|
+
import { handleApiMe } from "../api-me.ts";
|
|
24
|
+
import { CSRF_COOKIE_NAME } from "../csrf.ts";
|
|
25
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
26
|
+
import { recordTokenMint } from "../jwt-sign.ts";
|
|
27
|
+
import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
|
|
28
|
+
import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
|
|
29
|
+
import { _resetTotpReplayCache, generateTotpSecret } from "../totp.ts";
|
|
30
|
+
import { isTotpEnrolled, persistEnrollment } from "../two-factor-store.ts";
|
|
31
|
+
import { createUser, verifyPassword } from "../users.ts";
|
|
32
|
+
|
|
33
|
+
const TEST_CSRF = "csrf-account-2fa-token";
|
|
34
|
+
const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
|
|
35
|
+
|
|
36
|
+
interface Harness {
|
|
37
|
+
db: Database;
|
|
38
|
+
configDir: string;
|
|
39
|
+
cleanup: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeHarness(): Harness {
|
|
43
|
+
const configDir = mkdtempSync(join(tmpdir(), "phub-api-account-2fa-"));
|
|
44
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
45
|
+
return {
|
|
46
|
+
db,
|
|
47
|
+
configDir,
|
|
48
|
+
cleanup: () => {
|
|
49
|
+
db.close();
|
|
50
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function userWithSession(
|
|
56
|
+
db: Database,
|
|
57
|
+
username: string,
|
|
58
|
+
password: string,
|
|
59
|
+
): Promise<{ userId: string; cookie: string }> {
|
|
60
|
+
const user = await createUser(db, username, password, { passwordChanged: true });
|
|
61
|
+
const session = createSession(db, { userId: user.id });
|
|
62
|
+
const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
|
|
63
|
+
return { userId: user.id, cookie };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function post(
|
|
67
|
+
subpath: string,
|
|
68
|
+
cookie: string | null,
|
|
69
|
+
body: Record<string, unknown>,
|
|
70
|
+
): Promise<Response> {
|
|
71
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
72
|
+
if (cookie) headers.cookie = cookie;
|
|
73
|
+
return handleApiAccount(
|
|
74
|
+
new Request(`http://hub.test/api/account${subpath}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers,
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
}),
|
|
79
|
+
subpath,
|
|
80
|
+
{ db: harness.db },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function liveCode(secretBase32: string, label = "owner"): string {
|
|
85
|
+
return new OTPAuth.TOTP({
|
|
86
|
+
issuer: "Parachute Hub",
|
|
87
|
+
label,
|
|
88
|
+
algorithm: "SHA1",
|
|
89
|
+
digits: 6,
|
|
90
|
+
period: 30,
|
|
91
|
+
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
|
92
|
+
}).generate();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let harness: Harness;
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
harness = makeHarness();
|
|
98
|
+
resetRateLimit();
|
|
99
|
+
_resetTotpReplayCache();
|
|
100
|
+
});
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
harness.cleanup();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("/api/account/* — auth posture", () => {
|
|
106
|
+
test("no session → 401", async () => {
|
|
107
|
+
const res = await post("/password", null, {
|
|
108
|
+
__csrf: TEST_CSRF,
|
|
109
|
+
current_password: "x",
|
|
110
|
+
new_password: "y",
|
|
111
|
+
});
|
|
112
|
+
expect(res.status).toBe(401);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("wrong CSRF → 403", async () => {
|
|
116
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
117
|
+
const res = await post("/password", cookie, {
|
|
118
|
+
__csrf: "not-the-token",
|
|
119
|
+
current_password: "owner-password-123",
|
|
120
|
+
new_password: "brand-new-passphrase",
|
|
121
|
+
});
|
|
122
|
+
expect(res.status).toBe(403);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("unknown subpath → 404", async () => {
|
|
126
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
127
|
+
const res = await post("/bogus", cookie, { __csrf: TEST_CSRF });
|
|
128
|
+
expect(res.status).toBe(404);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("GET → 405", async () => {
|
|
132
|
+
const res = await handleApiAccount(
|
|
133
|
+
new Request("http://hub.test/api/account/password", { method: "GET" }),
|
|
134
|
+
"/password",
|
|
135
|
+
{ db: harness.db },
|
|
136
|
+
);
|
|
137
|
+
expect(res.status).toBe(405);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("/api/account/password", () => {
|
|
142
|
+
test("happy path rotates the hash + revokes active tokens", async () => {
|
|
143
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
144
|
+
// Seed an active token for this user — it should be revoked.
|
|
145
|
+
recordTokenMint(harness.db, {
|
|
146
|
+
jti: "tok-1",
|
|
147
|
+
userId,
|
|
148
|
+
subject: userId,
|
|
149
|
+
clientId: "cli",
|
|
150
|
+
scopes: ["vault:default:read"],
|
|
151
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
152
|
+
createdVia: "cli_mint",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const res = await post("/password", cookie, {
|
|
156
|
+
__csrf: TEST_CSRF,
|
|
157
|
+
current_password: "owner-password-123",
|
|
158
|
+
new_password: "brand-new-passphrase",
|
|
159
|
+
});
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
|
|
162
|
+
// New password verifies; old does not.
|
|
163
|
+
const row = harness.db
|
|
164
|
+
.query<{ password_hash: string }, [string]>("SELECT password_hash FROM users WHERE id = ?")
|
|
165
|
+
.get(userId);
|
|
166
|
+
expect(row).not.toBeNull();
|
|
167
|
+
const fakeUser = { passwordHash: row!.password_hash } as Parameters<typeof verifyPassword>[0];
|
|
168
|
+
expect(await verifyPassword(fakeUser, "brand-new-passphrase")).toBe(true);
|
|
169
|
+
expect(await verifyPassword(fakeUser, "owner-password-123")).toBe(false);
|
|
170
|
+
|
|
171
|
+
// Token revoked.
|
|
172
|
+
const tok = harness.db
|
|
173
|
+
.query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
|
|
174
|
+
.get("tok-1");
|
|
175
|
+
expect(tok?.revoked_at).not.toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("wrong current password → 401 invalid_credentials", async () => {
|
|
179
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
180
|
+
const res = await post("/password", cookie, {
|
|
181
|
+
__csrf: TEST_CSRF,
|
|
182
|
+
current_password: "WRONG",
|
|
183
|
+
new_password: "brand-new-passphrase",
|
|
184
|
+
});
|
|
185
|
+
expect(res.status).toBe(401);
|
|
186
|
+
const body = (await res.json()) as { error: string };
|
|
187
|
+
expect(body.error).toBe("invalid_credentials");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("new password too short → 400 invalid_password", async () => {
|
|
191
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
192
|
+
const res = await post("/password", cookie, {
|
|
193
|
+
__csrf: TEST_CSRF,
|
|
194
|
+
current_password: "owner-password-123",
|
|
195
|
+
new_password: "short",
|
|
196
|
+
});
|
|
197
|
+
expect(res.status).toBe(400);
|
|
198
|
+
const body = (await res.json()) as { error: string };
|
|
199
|
+
expect(body.error).toBe("invalid_password");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("new === current → 400 password_unchanged", async () => {
|
|
203
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
204
|
+
const res = await post("/password", cookie, {
|
|
205
|
+
__csrf: TEST_CSRF,
|
|
206
|
+
current_password: "owner-password-123",
|
|
207
|
+
new_password: "owner-password-123",
|
|
208
|
+
});
|
|
209
|
+
expect(res.status).toBe(400);
|
|
210
|
+
const body = (await res.json()) as { error: string };
|
|
211
|
+
expect(body.error).toBe("password_unchanged");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("missing fields → 400", async () => {
|
|
215
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
216
|
+
const res = await post("/password", cookie, { __csrf: TEST_CSRF });
|
|
217
|
+
expect(res.status).toBe(400);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("new password over PASSWORD_MAX_LEN → 413 (before argon2id hash)", async () => {
|
|
221
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
222
|
+
const res = await post("/password", cookie, {
|
|
223
|
+
__csrf: TEST_CSRF,
|
|
224
|
+
current_password: "owner-password-123",
|
|
225
|
+
new_password: "x".repeat(257),
|
|
226
|
+
});
|
|
227
|
+
expect(res.status).toBe(413);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("rate-limited after repeated wrong-current attempts → 429", async () => {
|
|
231
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
232
|
+
// Bucket is 3 attempts / 5 min (CHANGE_PASSWORD_*). Burn 3 wrong-current
|
|
233
|
+
// attempts (each 401), then the 4th is rejected at 429 BEFORE the verify.
|
|
234
|
+
for (let i = 0; i < 3; i++) {
|
|
235
|
+
const r = await post("/password", cookie, {
|
|
236
|
+
__csrf: TEST_CSRF,
|
|
237
|
+
current_password: "WRONG",
|
|
238
|
+
new_password: "brand-new-passphrase",
|
|
239
|
+
});
|
|
240
|
+
expect(r.status).toBe(401);
|
|
241
|
+
}
|
|
242
|
+
const limited = await post("/password", cookie, {
|
|
243
|
+
__csrf: TEST_CSRF,
|
|
244
|
+
current_password: "WRONG",
|
|
245
|
+
new_password: "brand-new-passphrase",
|
|
246
|
+
});
|
|
247
|
+
expect(limited.status).toBe(429);
|
|
248
|
+
expect(limited.headers.get("retry-after")).not.toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("/api/account/2fa start + confirm", () => {
|
|
253
|
+
test("start returns a secret + otpauth_url + qr_data_url", async () => {
|
|
254
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
255
|
+
const res = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
const body = (await res.json()) as {
|
|
258
|
+
secret: string;
|
|
259
|
+
otpauth_url: string;
|
|
260
|
+
qr_data_url: string;
|
|
261
|
+
};
|
|
262
|
+
expect(body.secret).toMatch(/^[A-Z2-7]+$/);
|
|
263
|
+
expect(body.otpauth_url.startsWith("otpauth://totp/")).toBe(true);
|
|
264
|
+
expect(body.qr_data_url.startsWith("data:image/png;base64,")).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("start refuses (409) when already enrolled", async () => {
|
|
268
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
269
|
+
await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
|
|
270
|
+
const res = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
271
|
+
expect(res.status).toBe(409);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("confirm with a live code persists enrollment + returns backup codes", async () => {
|
|
275
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
276
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
277
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
278
|
+
|
|
279
|
+
const confirmRes = await post("/2fa/confirm", cookie, {
|
|
280
|
+
__csrf: TEST_CSRF,
|
|
281
|
+
secret,
|
|
282
|
+
code: liveCode(secret),
|
|
283
|
+
});
|
|
284
|
+
expect(confirmRes.status).toBe(200);
|
|
285
|
+
const body = (await confirmRes.json()) as { enrolled: boolean; backup_codes: string[] };
|
|
286
|
+
expect(body.enrolled).toBe(true);
|
|
287
|
+
expect(body.backup_codes.length).toBe(10);
|
|
288
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("confirm with a wrong code → 400 invalid_code (not persisted)", async () => {
|
|
292
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
293
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
294
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
295
|
+
const res = await post("/2fa/confirm", cookie, {
|
|
296
|
+
__csrf: TEST_CSRF,
|
|
297
|
+
secret,
|
|
298
|
+
code: "000000",
|
|
299
|
+
});
|
|
300
|
+
expect(res.status).toBe(400);
|
|
301
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("confirm with a malformed secret → 400 setup_expired", async () => {
|
|
305
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
306
|
+
const res = await post("/2fa/confirm", cookie, {
|
|
307
|
+
__csrf: TEST_CSRF,
|
|
308
|
+
secret: "not-base32!!",
|
|
309
|
+
code: "123456",
|
|
310
|
+
});
|
|
311
|
+
expect(res.status).toBe(400);
|
|
312
|
+
const body = (await res.json()) as { error: string };
|
|
313
|
+
expect(body.error).toBe("setup_expired");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("confirm is rate-limited after 10 attempts → 429 (lenient, #712)", async () => {
|
|
317
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
318
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
319
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
320
|
+
// 10 honest mistypes are admitted (each 400 invalid_code) — the lenient
|
|
321
|
+
// bucket doesn't punish a fumbling enroller.
|
|
322
|
+
for (let i = 0; i < 10; i++) {
|
|
323
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
324
|
+
__csrf: TEST_CSRF,
|
|
325
|
+
secret,
|
|
326
|
+
code: "000000",
|
|
327
|
+
});
|
|
328
|
+
expect(r.status).toBe(400);
|
|
329
|
+
}
|
|
330
|
+
// 11th is denied by the limiter BEFORE the code is checked.
|
|
331
|
+
const denied = await post("/2fa/confirm", cookie, {
|
|
332
|
+
__csrf: TEST_CSRF,
|
|
333
|
+
secret,
|
|
334
|
+
code: "000000",
|
|
335
|
+
});
|
|
336
|
+
expect(denied.status).toBe(429);
|
|
337
|
+
const body = (await denied.json()) as { error: string };
|
|
338
|
+
expect(body.error).toBe("too_many_attempts");
|
|
339
|
+
expect(denied.headers.get("retry-after")).toBeTruthy();
|
|
340
|
+
// The grind never touched enrollment.
|
|
341
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("malformed-secret POSTs don't burn the confirm budget (#712)", async () => {
|
|
345
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
346
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
347
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
348
|
+
// 10 junk POSTs are rejected by the format guard BEFORE the limiter runs.
|
|
349
|
+
for (let i = 0; i < 10; i++) {
|
|
350
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
351
|
+
__csrf: TEST_CSRF,
|
|
352
|
+
secret: "not-base32!!",
|
|
353
|
+
code: "000000",
|
|
354
|
+
});
|
|
355
|
+
expect(r.status).toBe(400);
|
|
356
|
+
}
|
|
357
|
+
// Budget untouched — the legit live code still enrolls on the next attempt.
|
|
358
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
359
|
+
__csrf: TEST_CSRF,
|
|
360
|
+
secret,
|
|
361
|
+
code: liveCode(secret),
|
|
362
|
+
});
|
|
363
|
+
expect(ok.status).toBe(200);
|
|
364
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a few mistypes then the live code within budget still enrolls (lenient)", async () => {
|
|
368
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
369
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
370
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
371
|
+
for (let i = 0; i < 3; i++) {
|
|
372
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
373
|
+
__csrf: TEST_CSRF,
|
|
374
|
+
secret,
|
|
375
|
+
code: "000000",
|
|
376
|
+
});
|
|
377
|
+
expect(r.status).toBe(400);
|
|
378
|
+
}
|
|
379
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
380
|
+
__csrf: TEST_CSRF,
|
|
381
|
+
secret,
|
|
382
|
+
code: liveCode(secret),
|
|
383
|
+
});
|
|
384
|
+
expect(ok.status).toBe(200);
|
|
385
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("/api/account/2fa/disable", () => {
|
|
390
|
+
test("password-gated: wrong password → 401, enrollment intact", async () => {
|
|
391
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
392
|
+
await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
|
|
393
|
+
const res = await post("/2fa/disable", cookie, {
|
|
394
|
+
__csrf: TEST_CSRF,
|
|
395
|
+
password: "WRONG",
|
|
396
|
+
});
|
|
397
|
+
expect(res.status).toBe(401);
|
|
398
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("correct password clears enrollment", async () => {
|
|
402
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
403
|
+
await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
|
|
404
|
+
const res = await post("/2fa/disable", cookie, {
|
|
405
|
+
__csrf: TEST_CSRF,
|
|
406
|
+
password: "owner-password-123",
|
|
407
|
+
});
|
|
408
|
+
expect(res.status).toBe(200);
|
|
409
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(false);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("idempotent when already off", async () => {
|
|
413
|
+
const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
414
|
+
const res = await post("/2fa/disable", cookie, {
|
|
415
|
+
__csrf: TEST_CSRF,
|
|
416
|
+
password: "owner-password-123",
|
|
417
|
+
});
|
|
418
|
+
expect(res.status).toBe(200);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("missing password → 400", async () => {
|
|
422
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
423
|
+
await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
|
|
424
|
+
const res = await post("/2fa/disable", cookie, { __csrf: TEST_CSRF });
|
|
425
|
+
expect(res.status).toBe(400);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe("/api/me — two_factor_enabled", () => {
|
|
430
|
+
test("false when not enrolled, true after enrollment", async () => {
|
|
431
|
+
const user = await createUser(harness.db, "owner", "owner-password-123", {
|
|
432
|
+
passwordChanged: true,
|
|
433
|
+
});
|
|
434
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
435
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
436
|
+
|
|
437
|
+
const before = await handleApiMe(
|
|
438
|
+
new Request("http://hub.test/api/me", { headers: { cookie } }),
|
|
439
|
+
{ db: harness.db },
|
|
440
|
+
);
|
|
441
|
+
const beforeBody = (await before.json()) as { two_factor_enabled?: boolean };
|
|
442
|
+
expect(beforeBody.two_factor_enabled).toBe(false);
|
|
443
|
+
|
|
444
|
+
await persistEnrollment(harness.db, user.id, generateTotpSecret("owner").secret);
|
|
445
|
+
|
|
446
|
+
const after = await handleApiMe(
|
|
447
|
+
new Request("http://hub.test/api/me", { headers: { cookie } }),
|
|
448
|
+
{ db: harness.db },
|
|
449
|
+
);
|
|
450
|
+
const afterBody = (await after.json()) as { two_factor_enabled?: boolean };
|
|
451
|
+
expect(afterBody.two_factor_enabled).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
@@ -323,8 +323,15 @@ describe("POST /api/hub/upgrade — redeploy-required short-circuit (§5.3)", ()
|
|
|
323
323
|
});
|
|
324
324
|
|
|
325
325
|
describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", () => {
|
|
326
|
-
/** Seed the status file with a prior op in the given phase.
|
|
327
|
-
|
|
326
|
+
/** Seed the status file with a prior op in the given phase. `startedAt`
|
|
327
|
+
* defaults to now (a FRESH in-flight slot); pass an old ISO string to seed a
|
|
328
|
+
* stale / abandoned slot for the #506 TTL tests. */
|
|
329
|
+
function seedStatus(
|
|
330
|
+
dir: string,
|
|
331
|
+
phase: HubUpgradeStatus["phase"],
|
|
332
|
+
opId = "prior-op",
|
|
333
|
+
startedAt: string = new Date().toISOString(),
|
|
334
|
+
): void {
|
|
328
335
|
writeHubUpgradeStatus(dir, {
|
|
329
336
|
operation_id: opId,
|
|
330
337
|
phase,
|
|
@@ -333,7 +340,7 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
|
|
|
333
340
|
target_version: "0.6.3-rc.2",
|
|
334
341
|
channel: "rc",
|
|
335
342
|
log: [],
|
|
336
|
-
started_at:
|
|
343
|
+
started_at: startedAt,
|
|
337
344
|
});
|
|
338
345
|
}
|
|
339
346
|
|
|
@@ -385,6 +392,55 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
|
|
|
385
392
|
expect(res.status).toBe(202);
|
|
386
393
|
expect(spawned.length).toBe(1);
|
|
387
394
|
});
|
|
395
|
+
|
|
396
|
+
// #506: a crashed helper leaves an in-flight slot stuck forever — without a
|
|
397
|
+
// TTL it 409-deadlocks every future upgrade. A STALE in-flight slot must be
|
|
398
|
+
// treated as abandoned so the new request proceeds.
|
|
399
|
+
for (const phase of ["pending", "running", "restarting"] as const) {
|
|
400
|
+
test(`#506: STALE in-flight slot (phase=${phase}, started 30m ago) → proceeds, not 409`, async () => {
|
|
401
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
402
|
+
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
403
|
+
seedStatus(harness.dir, phase, "crashed-op", thirtyMinAgo);
|
|
404
|
+
const { deps, spawned } = baseDeps(harness);
|
|
405
|
+
const res = await handleHubUpgrade(
|
|
406
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
407
|
+
deps,
|
|
408
|
+
);
|
|
409
|
+
// Abandoned slot freed: a fresh op took over + spawned its helper.
|
|
410
|
+
expect(res.status).toBe(202);
|
|
411
|
+
expect(spawned.length).toBe(1);
|
|
412
|
+
const status = readHubUpgradeStatus(harness.dir);
|
|
413
|
+
expect(status?.operation_id).not.toBe("crashed-op");
|
|
414
|
+
expect(spawned[0]?.operationId).toBe(status?.operation_id);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
test("#506: FRESH in-flight slot (started just now) → still 409", async () => {
|
|
419
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
420
|
+
// Just-started (well within the 15m TTL) → a real, live upgrade → 409.
|
|
421
|
+
seedStatus(harness.dir, "running", "live-op", new Date().toISOString());
|
|
422
|
+
const { deps, spawned } = baseDeps(harness);
|
|
423
|
+
const res = await handleHubUpgrade(
|
|
424
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
425
|
+
deps,
|
|
426
|
+
);
|
|
427
|
+
expect(res.status).toBe(409);
|
|
428
|
+
expect(spawned.length).toBe(0);
|
|
429
|
+
expect(readHubUpgradeStatus(harness.dir)?.operation_id).toBe("live-op");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("#506: in-flight slot with a malformed started_at → treated as stale, proceeds", async () => {
|
|
433
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
434
|
+
seedStatus(harness.dir, "running", "garbage-op", "not-a-date");
|
|
435
|
+
const { deps, spawned } = baseDeps(harness);
|
|
436
|
+
const res = await handleHubUpgrade(
|
|
437
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
438
|
+
deps,
|
|
439
|
+
);
|
|
440
|
+
// An unparseable timestamp must not deadlock — treat as abandoned.
|
|
441
|
+
expect(res.status).toBe(202);
|
|
442
|
+
expect(spawned.length).toBe(1);
|
|
443
|
+
});
|
|
388
444
|
});
|
|
389
445
|
|
|
390
446
|
describe("appendHubUpgradeStatus — operation_id guard (stale-helper isolation)", () => {
|