@openparachute/hub 0.7.4-rc.9 → 0.7.4
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 +1 -1
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- 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 +298 -0
- 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-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +207 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -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/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +14 -1
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- 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
|
+
});
|
|
@@ -172,6 +172,81 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
172
172
|
}
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
// hub#516 parity — the live "mint refused" after `parachute hub set-origin`.
|
|
176
|
+
// An operator/agent credential minted under a PRIOR origin (still a member of
|
|
177
|
+
// the hub's bound-origin set) must keep minting after the canonical issuer
|
|
178
|
+
// switches; the minted token still carries the new canonical issuer.
|
|
179
|
+
describe("multi-origin issuer set (set-origin parity)", () => {
|
|
180
|
+
const TUNNEL = "https://brain.gitcoin.co";
|
|
181
|
+
|
|
182
|
+
test("mints when the bearer's iss is in knownIssuers but ≠ the canonical issuer", async () => {
|
|
183
|
+
const h = makeHarness();
|
|
184
|
+
try {
|
|
185
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
186
|
+
try {
|
|
187
|
+
// Operator token minted under the TUNNEL origin (pre-`set-origin`).
|
|
188
|
+
const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
|
|
189
|
+
const resp = await handleApiMintToken(
|
|
190
|
+
jsonRequest(
|
|
191
|
+
{ scope: "scribe:transcribe", expires_in: 3600 },
|
|
192
|
+
{ authorization: `Bearer ${op.token}` },
|
|
193
|
+
),
|
|
194
|
+
// Canonical issuer is now ISSUER (loopback), but the bound set still
|
|
195
|
+
// includes TUNNEL — the still-valid prior origin.
|
|
196
|
+
{ db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
|
|
197
|
+
);
|
|
198
|
+
expect(resp.status).toBe(200);
|
|
199
|
+
const body = (await resp.json()) as { token: string };
|
|
200
|
+
// The MINTED token carries the canonical issuer, not the bearer's.
|
|
201
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
202
|
+
expect(validated.payload.iss).toBe(ISSUER);
|
|
203
|
+
} finally {
|
|
204
|
+
db.close();
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
h.cleanup();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("rejects 401 when the bearer's iss is OUTSIDE knownIssuers", async () => {
|
|
212
|
+
const h = makeHarness();
|
|
213
|
+
try {
|
|
214
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
215
|
+
try {
|
|
216
|
+
const op = await mintOperatorToken(db, userId, { issuer: "https://evil.example.com" });
|
|
217
|
+
const resp = await handleApiMintToken(
|
|
218
|
+
jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
|
|
219
|
+
{ db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
|
|
220
|
+
);
|
|
221
|
+
expect(resp.status).toBe(401);
|
|
222
|
+
} finally {
|
|
223
|
+
db.close();
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
h.cleanup();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("back-compat: without knownIssuers, a non-canonical iss is still rejected", async () => {
|
|
231
|
+
const h = makeHarness();
|
|
232
|
+
try {
|
|
233
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
234
|
+
try {
|
|
235
|
+
const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
|
|
236
|
+
const resp = await handleApiMintToken(
|
|
237
|
+
jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
|
|
238
|
+
{ db, issuer: ISSUER }, // no knownIssuers → falls back to [ISSUER]
|
|
239
|
+
);
|
|
240
|
+
expect(resp.status).toBe(401);
|
|
241
|
+
} finally {
|
|
242
|
+
db.close();
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
h.cleanup();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
175
250
|
test("happy path: --scope-set=auth narrow operator token also passes the scope gate", async () => {
|
|
176
251
|
const h = makeHarness();
|
|
177
252
|
try {
|
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
API_MODULES_CHANNEL_REQUIRED_SCOPE,
|
|
7
7
|
API_MODULES_REQUIRED_SCOPE,
|
|
8
8
|
_clearLatestVersionCacheForTests,
|
|
9
|
+
defaultReadInstalledVersion,
|
|
9
10
|
handleApiModules,
|
|
10
11
|
handleApiModulesChannel,
|
|
12
|
+
isUpgradeAvailable,
|
|
11
13
|
} from "../api-modules.ts";
|
|
12
14
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
15
|
import { getSetting, setModuleInstallChannel } from "../hub-settings.ts";
|
|
@@ -491,6 +493,147 @@ describe("GET /api/modules", () => {
|
|
|
491
493
|
expect(scribe?.installed_version).toBeNull();
|
|
492
494
|
});
|
|
493
495
|
|
|
496
|
+
// ── hub#243: upgrade-offer must be semver-aware + installed-version must be live ──
|
|
497
|
+
|
|
498
|
+
type UpgradeWire = {
|
|
499
|
+
short: string;
|
|
500
|
+
installed_version: string | null;
|
|
501
|
+
latest_version: string | null;
|
|
502
|
+
upgrade_available: boolean;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
async function modulesWith(opts: {
|
|
506
|
+
installedVersion: string;
|
|
507
|
+
latest: string | null;
|
|
508
|
+
readInstalledVersion?: (installDir: string) => string | null;
|
|
509
|
+
}): Promise<UpgradeWire[]> {
|
|
510
|
+
writeManifest(h.manifestPath, [
|
|
511
|
+
{
|
|
512
|
+
name: "parachute-vault",
|
|
513
|
+
port: 1940,
|
|
514
|
+
paths: ["/vault/default"],
|
|
515
|
+
health: "/vault/default/health",
|
|
516
|
+
version: opts.installedVersion,
|
|
517
|
+
installDir: "/install/dir/vault",
|
|
518
|
+
},
|
|
519
|
+
]);
|
|
520
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
521
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
522
|
+
db: h.db,
|
|
523
|
+
issuer: ISSUER,
|
|
524
|
+
manifestPath: h.manifestPath,
|
|
525
|
+
fetchLatestVersion: async () => opts.latest,
|
|
526
|
+
// Default: no live read (synthetic install dir has no package.json), so
|
|
527
|
+
// the services.json cache is used — matching the prior behavior.
|
|
528
|
+
readInstalledVersion: opts.readInstalledVersion ?? (() => null),
|
|
529
|
+
});
|
|
530
|
+
const body = (await res.json()) as { modules: UpgradeWire[] };
|
|
531
|
+
return body.modules;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
test("does NOT offer an upgrade when the channel target is OLDER than installed (the live downgrade bug)", async () => {
|
|
535
|
+
// The exact live shape: rc operator installed 0.6.4-rc.15; channel resolved
|
|
536
|
+
// latest_version to the OLDER @latest 0.6.3. Strings differ, but it's a
|
|
537
|
+
// downgrade — upgrade_available MUST be false.
|
|
538
|
+
const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.3" });
|
|
539
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
540
|
+
expect(vault?.installed_version).toBe("0.6.4-rc.15");
|
|
541
|
+
expect(vault?.latest_version).toBe("0.6.3");
|
|
542
|
+
expect(vault?.upgrade_available).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("offers an upgrade for a real rc → newer-rc move", async () => {
|
|
546
|
+
const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4-rc.16" });
|
|
547
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
548
|
+
expect(vault?.upgrade_available).toBe(true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("offers an upgrade for rc → its own stable (stable > its rc per semver)", async () => {
|
|
552
|
+
const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4" });
|
|
553
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
554
|
+
expect(vault?.upgrade_available).toBe(true);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("offers an upgrade for a plain stable → newer stable", async () => {
|
|
558
|
+
const mods = await modulesWith({ installedVersion: "0.4.5", latest: "0.5.0" });
|
|
559
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
560
|
+
expect(vault?.upgrade_available).toBe(true);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("no upgrade when installed === latest", async () => {
|
|
564
|
+
const mods = await modulesWith({ installedVersion: "0.5.0", latest: "0.5.0" });
|
|
565
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
566
|
+
expect(vault?.upgrade_available).toBe(false);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("no upgrade when the npm probe failed (latest_version null)", async () => {
|
|
570
|
+
const mods = await modulesWith({ installedVersion: "0.5.0", latest: null });
|
|
571
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
572
|
+
expect(vault?.latest_version).toBeNull();
|
|
573
|
+
expect(vault?.upgrade_available).toBe(false);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("installed_version reflects the LIVE on-disk version, not a stale services.json cache (hub#243)", async () => {
|
|
577
|
+
// services.json cache lags the bun-linked checkout: cache says 0.5.4-rc.15
|
|
578
|
+
// (the live symptom) while package.json on disk is already 0.6.4-rc.15.
|
|
579
|
+
// The admin view must show what's actually installed.
|
|
580
|
+
const mods = await modulesWith({
|
|
581
|
+
installedVersion: "0.5.4-rc.15",
|
|
582
|
+
latest: "0.6.3",
|
|
583
|
+
readInstalledVersion: (dir) => (dir === "/install/dir/vault" ? "0.6.4-rc.15" : null),
|
|
584
|
+
});
|
|
585
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
586
|
+
expect(vault?.installed_version).toBe("0.6.4-rc.15");
|
|
587
|
+
// And with the corrected current, @latest 0.6.3 is still a downgrade → no offer.
|
|
588
|
+
expect(vault?.upgrade_available).toBe(false);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("falls back to the services.json version when the live read returns null", async () => {
|
|
592
|
+
const mods = await modulesWith({
|
|
593
|
+
installedVersion: "0.6.4-rc.15",
|
|
594
|
+
latest: "0.6.4-rc.16",
|
|
595
|
+
readInstalledVersion: () => null,
|
|
596
|
+
});
|
|
597
|
+
const vault = mods.find((m) => m.short === "vault");
|
|
598
|
+
expect(vault?.installed_version).toBe("0.6.4-rc.15");
|
|
599
|
+
expect(vault?.upgrade_available).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("isUpgradeAvailable: semver-aware, fail-closed on unparseable + nulls", () => {
|
|
603
|
+
// strictly-newer → true
|
|
604
|
+
expect(isUpgradeAvailable("0.4.5", "0.5.0")).toBe(true);
|
|
605
|
+
expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4-rc.16")).toBe(true);
|
|
606
|
+
expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4")).toBe(true); // stable > its rc
|
|
607
|
+
// same / older → false
|
|
608
|
+
expect(isUpgradeAvailable("0.5.0", "0.5.0")).toBe(false);
|
|
609
|
+
expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.3")).toBe(false); // the live downgrade
|
|
610
|
+
expect(isUpgradeAvailable("0.6.4", "0.6.4-rc.15")).toBe(false); // stable → its rc
|
|
611
|
+
// nulls → false (not installed / probe failed)
|
|
612
|
+
expect(isUpgradeAvailable(null, "0.5.0")).toBe(false);
|
|
613
|
+
expect(isUpgradeAvailable("0.5.0", null)).toBe(false);
|
|
614
|
+
// unparseable → false (fail-closed: never offer a move we can't verify)
|
|
615
|
+
expect(isUpgradeAvailable("not-a-version", "0.5.0")).toBe(false);
|
|
616
|
+
expect(isUpgradeAvailable("0.5.0", "garbage")).toBe(false);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("defaultReadInstalledVersion reads package.json version + tolerates missing/bad files", () => {
|
|
620
|
+
const tmp = mkdtempSync(join(tmpdir(), "phub-live-ver-"));
|
|
621
|
+
try {
|
|
622
|
+
writeFileSync(join(tmp, "package.json"), JSON.stringify({ version: "0.6.4-rc.15" }));
|
|
623
|
+
expect(defaultReadInstalledVersion(tmp)).toBe("0.6.4-rc.15");
|
|
624
|
+
// Missing dir / no package.json → null.
|
|
625
|
+
expect(defaultReadInstalledVersion(join(tmp, "does-not-exist"))).toBeNull();
|
|
626
|
+
// Malformed JSON → null (no throw).
|
|
627
|
+
writeFileSync(join(tmp, "package.json"), "{ not json");
|
|
628
|
+
expect(defaultReadInstalledVersion(tmp)).toBeNull();
|
|
629
|
+
// No version field → null.
|
|
630
|
+
writeFileSync(join(tmp, "package.json"), JSON.stringify({ name: "x" }));
|
|
631
|
+
expect(defaultReadInstalledVersion(tmp)).toBeNull();
|
|
632
|
+
} finally {
|
|
633
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
494
637
|
test("includes supervisor status + pid when a supervisor is injected", async () => {
|
|
495
638
|
writeManifest(h.manifestPath, [
|
|
496
639
|
{
|