@openparachute/hub 0.6.3 → 0.6.4-rc.1
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__/account-setup.test.ts +609 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +180 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +342 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +94 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +347 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/package.json
CHANGED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redemption-flow tests for one-time invite links —
|
|
3
|
+
* `GET|POST /account/setup/<token>` (`account-setup.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Adversarial coverage of the redeem path + the security invariants:
|
|
6
|
+
* - happy path: creates user + vault + session, invite marked used
|
|
7
|
+
* - replay rejected (used_at set → 410)
|
|
8
|
+
* - expired rejected (410)
|
|
9
|
+
* - revoked rejected (410)
|
|
10
|
+
* - tampered/unknown token → 404
|
|
11
|
+
* - createUser fails → invite STILL re-usable (the ordering guarantee)
|
|
12
|
+
* - INVARIANT: the redeemed user holds ONLY their one vault at the
|
|
13
|
+
* invite's role — never host:admin, never another vault
|
|
14
|
+
*
|
|
15
|
+
* Vault provisioning is stubbed via `runCommand`: the stub appends the
|
|
16
|
+
* named vault to services.json (what `parachute-vault create` does) so
|
|
17
|
+
* `provisionVault`'s post-orchestrate re-read finds it — no real shell-out.
|
|
18
|
+
*/
|
|
19
|
+
import type { Database } from "bun:sqlite";
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
21
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { handleAccountSetupGet, handleAccountSetupPost } from "../account-setup.ts";
|
|
25
|
+
import type { RunResult } from "../admin-vaults.ts";
|
|
26
|
+
import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
27
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
28
|
+
import { consumeInvite, findInviteByRawToken, issueInvite, revokeInvite } from "../invites.ts";
|
|
29
|
+
import { __resetForTests } from "../rate-limit.ts";
|
|
30
|
+
import { findActiveSession } from "../sessions.ts";
|
|
31
|
+
import { createUser, getUserByUsernameCI, userCount, vaultVerbsForUserVault } from "../users.ts";
|
|
32
|
+
|
|
33
|
+
const ISSUER = "https://hub.test";
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
db: Database;
|
|
37
|
+
manifestPath: string;
|
|
38
|
+
/** Names of vaults the stubbed `runCommand` has "created". */
|
|
39
|
+
cleanup: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeHarness(): Harness {
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-setup-"));
|
|
44
|
+
const db = openHubDb(hubDbPath(dir));
|
|
45
|
+
const manifestPath = join(dir, "services.json");
|
|
46
|
+
// Seed with vault registered (one path) so the create branch runs
|
|
47
|
+
// `parachute-vault create <name>` rather than the bootstrap install.
|
|
48
|
+
writeFileSync(
|
|
49
|
+
manifestPath,
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
services: [
|
|
52
|
+
{
|
|
53
|
+
name: "parachute-vault",
|
|
54
|
+
port: 4101,
|
|
55
|
+
paths: ["/vault/seed"],
|
|
56
|
+
health: "/health",
|
|
57
|
+
version: "0.0.0-test",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
db,
|
|
64
|
+
manifestPath,
|
|
65
|
+
cleanup: () => {
|
|
66
|
+
db.close();
|
|
67
|
+
rmSync(dir, { recursive: true, force: true });
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let harness: Harness;
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
harness = makeHarness();
|
|
75
|
+
__resetForTests();
|
|
76
|
+
});
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
harness.cleanup();
|
|
79
|
+
__resetForTests();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Stub vault create: append the named vault path to services.json, emit create JSON. */
|
|
83
|
+
function makeStubRunCommand(opts: { fail?: boolean } = {}) {
|
|
84
|
+
const calls: string[][] = [];
|
|
85
|
+
const run = async (cmd: readonly string[]): Promise<RunResult> => {
|
|
86
|
+
calls.push([...cmd]);
|
|
87
|
+
if (opts.fail) return { exitCode: 1, stdout: "", stderr: "boom" };
|
|
88
|
+
// cmd = ["parachute-vault", "create", <name>, "--json", ...]
|
|
89
|
+
const name = cmd[2] ?? "";
|
|
90
|
+
const manifest = JSON.parse(readFileSync(harness.manifestPath, "utf8")) as {
|
|
91
|
+
services: { name: string; paths: string[] }[];
|
|
92
|
+
};
|
|
93
|
+
const vaultSvc = manifest.services.find((s) => s.name === "parachute-vault");
|
|
94
|
+
if (vaultSvc && !vaultSvc.paths.includes(`/vault/${name}`)) {
|
|
95
|
+
vaultSvc.paths.push(`/vault/${name}`);
|
|
96
|
+
writeFileSync(harness.manifestPath, JSON.stringify(manifest));
|
|
97
|
+
}
|
|
98
|
+
const createJson = {
|
|
99
|
+
name,
|
|
100
|
+
token: "",
|
|
101
|
+
paths: {
|
|
102
|
+
vault_dir: `/d/${name}`,
|
|
103
|
+
vault_db: `/d/${name}/v.db`,
|
|
104
|
+
vault_config: `/d/${name}/v.yaml`,
|
|
105
|
+
},
|
|
106
|
+
set_as_default: false,
|
|
107
|
+
};
|
|
108
|
+
return { exitCode: 0, stdout: JSON.stringify(createJson), stderr: "" };
|
|
109
|
+
};
|
|
110
|
+
return { run, calls };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function csrfPair(): { token: string; cookieFragment: string } {
|
|
114
|
+
const token = generateCsrfToken();
|
|
115
|
+
const cookie = buildCsrfCookie(token, { secure: false }).split(";")[0] ?? "";
|
|
116
|
+
return { token, cookieFragment: cookie };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function deps(runCommand?: (cmd: readonly string[]) => Promise<RunResult>) {
|
|
120
|
+
return {
|
|
121
|
+
db: harness.db,
|
|
122
|
+
hubOrigin: ISSUER,
|
|
123
|
+
manifestPath: harness.manifestPath,
|
|
124
|
+
...(runCommand !== undefined ? { runCommand } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Build a POST request with CSRF cookie + form body. */
|
|
129
|
+
function postReq(token: string, fields: Record<string, string>, csrfCookie: string): Request {
|
|
130
|
+
const form = new URLSearchParams(fields);
|
|
131
|
+
return new Request(`${ISSUER}/account/setup/${token}`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
135
|
+
cookie: csrfCookie,
|
|
136
|
+
},
|
|
137
|
+
body: form.toString(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe("GET /account/setup/<token>", () => {
|
|
142
|
+
test("renders the claim form for a valid invite", async () => {
|
|
143
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
144
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
|
|
145
|
+
const res = handleAccountSetupGet(
|
|
146
|
+
new Request(`${ISSUER}/account/setup/${rawToken}`),
|
|
147
|
+
rawToken,
|
|
148
|
+
deps(),
|
|
149
|
+
);
|
|
150
|
+
expect(res.status).toBe(200);
|
|
151
|
+
const html = await res.text();
|
|
152
|
+
expect(html).toContain("Claim your invite");
|
|
153
|
+
// Pinned vault → shown read-only, not a name-it field.
|
|
154
|
+
expect(html).toContain("maya");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("unknown token → 404", async () => {
|
|
158
|
+
const res = handleAccountSetupGet(new Request(`${ISSUER}/account/setup/nope`), "nope", deps());
|
|
159
|
+
expect(res.status).toBe(404);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("expired invite → 410", async () => {
|
|
163
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
164
|
+
const now = new Date("2026-06-04T00:00:00Z");
|
|
165
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
166
|
+
createdBy: admin.id,
|
|
167
|
+
expiresInSeconds: 60,
|
|
168
|
+
now: () => now,
|
|
169
|
+
});
|
|
170
|
+
const later = new Date(now.getTime() + 120_000);
|
|
171
|
+
const res = handleAccountSetupGet(
|
|
172
|
+
new Request(`${ISSUER}/account/setup/${rawToken}`),
|
|
173
|
+
rawToken,
|
|
174
|
+
{ ...deps(), now: () => later },
|
|
175
|
+
);
|
|
176
|
+
expect(res.status).toBe(410);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("POST /account/setup/<token> — happy path", () => {
|
|
181
|
+
test("creates user + vault + session, marks invite used", async () => {
|
|
182
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
183
|
+
const { rawToken, invite } = issueInvite(harness.db, {
|
|
184
|
+
createdBy: admin.id,
|
|
185
|
+
vaultName: "maya",
|
|
186
|
+
});
|
|
187
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
188
|
+
const stub = makeStubRunCommand();
|
|
189
|
+
const res = await handleAccountSetupPost(
|
|
190
|
+
postReq(
|
|
191
|
+
rawToken,
|
|
192
|
+
{
|
|
193
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
194
|
+
username: "maya",
|
|
195
|
+
password: "maya-strong-password-1",
|
|
196
|
+
password_confirm: "maya-strong-password-1",
|
|
197
|
+
},
|
|
198
|
+
cookieFragment,
|
|
199
|
+
),
|
|
200
|
+
rawToken,
|
|
201
|
+
deps(stub.run),
|
|
202
|
+
);
|
|
203
|
+
// 302 → /account/ with a session cookie.
|
|
204
|
+
expect(res.status).toBe(302);
|
|
205
|
+
expect(res.headers.get("location")).toBe("/account/");
|
|
206
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
207
|
+
expect(setCookie.length).toBeGreaterThan(0);
|
|
208
|
+
|
|
209
|
+
// User created, password_changed=true (chose their own → no force-change).
|
|
210
|
+
const user = getUserByUsernameCI(harness.db, "maya");
|
|
211
|
+
expect(user).not.toBeNull();
|
|
212
|
+
expect(user?.passwordChanged).toBe(true);
|
|
213
|
+
expect(user?.assignedVaults).toEqual(["maya"]);
|
|
214
|
+
|
|
215
|
+
// Vault provisioned via the create branch.
|
|
216
|
+
expect(stub.calls.some((c) => c[0] === "parachute-vault" && c[1] === "create")).toBe(true);
|
|
217
|
+
|
|
218
|
+
// Invite consumed.
|
|
219
|
+
const after = findInviteByRawToken(harness.db, rawToken);
|
|
220
|
+
expect(after?.usedAt).not.toBeNull();
|
|
221
|
+
expect(after?.redeemedUserId).toBe(user?.id ?? "");
|
|
222
|
+
|
|
223
|
+
// Session is live for that user.
|
|
224
|
+
const sid = setCookie.split(";")[0]?.split("=")[1] ?? "";
|
|
225
|
+
const sessionReq = new Request(`${ISSUER}/account/`, {
|
|
226
|
+
headers: { cookie: `parachute_hub_session=${sid}` },
|
|
227
|
+
});
|
|
228
|
+
const session = findActiveSession(harness.db, sessionReq);
|
|
229
|
+
expect(session?.userId).toBe(user?.id ?? "");
|
|
230
|
+
|
|
231
|
+
void invite;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("redeemer names their own vault when the invite doesn't pin one", async () => {
|
|
235
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
236
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id }); // vault_name null
|
|
237
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
238
|
+
const stub = makeStubRunCommand();
|
|
239
|
+
const res = await handleAccountSetupPost(
|
|
240
|
+
postReq(
|
|
241
|
+
rawToken,
|
|
242
|
+
{
|
|
243
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
244
|
+
username: "sam",
|
|
245
|
+
password: "sam-strong-password-12",
|
|
246
|
+
password_confirm: "sam-strong-password-12",
|
|
247
|
+
vault_name: "sams-vault",
|
|
248
|
+
},
|
|
249
|
+
cookieFragment,
|
|
250
|
+
),
|
|
251
|
+
rawToken,
|
|
252
|
+
deps(stub.run),
|
|
253
|
+
);
|
|
254
|
+
expect(res.status).toBe(302);
|
|
255
|
+
const user = getUserByUsernameCI(harness.db, "sam");
|
|
256
|
+
expect(user?.assignedVaults).toEqual(["sams-vault"]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("POST /account/setup/<token> — security invariants", () => {
|
|
261
|
+
test("redeemed user holds ONLY their vault at the invite role — NOT admin, NOT another vault", async () => {
|
|
262
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
263
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
|
|
264
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
265
|
+
const stub = makeStubRunCommand();
|
|
266
|
+
await handleAccountSetupPost(
|
|
267
|
+
postReq(
|
|
268
|
+
rawToken,
|
|
269
|
+
{
|
|
270
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
271
|
+
username: "maya",
|
|
272
|
+
password: "maya-strong-password-1",
|
|
273
|
+
password_confirm: "maya-strong-password-1",
|
|
274
|
+
},
|
|
275
|
+
cookieFragment,
|
|
276
|
+
),
|
|
277
|
+
rawToken,
|
|
278
|
+
deps(stub.run),
|
|
279
|
+
);
|
|
280
|
+
const user = getUserByUsernameCI(harness.db, "maya");
|
|
281
|
+
expect(user).not.toBeNull();
|
|
282
|
+
const id = user?.id ?? "";
|
|
283
|
+
|
|
284
|
+
// Exactly one vault assignment.
|
|
285
|
+
expect(user?.assignedVaults).toEqual(["maya"]);
|
|
286
|
+
// Role is 'write' (owner) → read/write/admin on THEIR vault only.
|
|
287
|
+
expect(vaultVerbsForUserVault(harness.db, id, "maya")).toEqual(["read", "write", "admin"]);
|
|
288
|
+
// No authority over any other vault.
|
|
289
|
+
expect(vaultVerbsForUserVault(harness.db, id, "seed")).toBeNull();
|
|
290
|
+
expect(vaultVerbsForUserVault(harness.db, id, "other")).toBeNull();
|
|
291
|
+
// The invited user is NOT the first admin (admin is the earliest row).
|
|
292
|
+
const firstId = harness.db
|
|
293
|
+
.query<{ id: string }, []>("SELECT id FROM users ORDER BY created_at ASC LIMIT 1")
|
|
294
|
+
.get()?.id;
|
|
295
|
+
expect(firstId).toBe(admin.id);
|
|
296
|
+
expect(firstId).not.toBe(id);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a 'read' invite lands a read-only assignment", async () => {
|
|
300
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
301
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
302
|
+
createdBy: admin.id,
|
|
303
|
+
vaultName: "shared",
|
|
304
|
+
role: "read",
|
|
305
|
+
});
|
|
306
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
307
|
+
const stub = makeStubRunCommand();
|
|
308
|
+
await handleAccountSetupPost(
|
|
309
|
+
postReq(
|
|
310
|
+
rawToken,
|
|
311
|
+
{
|
|
312
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
313
|
+
username: "guest",
|
|
314
|
+
password: "guest-strong-password-1",
|
|
315
|
+
password_confirm: "guest-strong-password-1",
|
|
316
|
+
},
|
|
317
|
+
cookieFragment,
|
|
318
|
+
),
|
|
319
|
+
rawToken,
|
|
320
|
+
deps(stub.run),
|
|
321
|
+
);
|
|
322
|
+
const user = getUserByUsernameCI(harness.db, "guest");
|
|
323
|
+
expect(vaultVerbsForUserVault(harness.db, user?.id ?? "", "shared")).toEqual(["read"]);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("POST /account/setup/<token> — rejection paths", () => {
|
|
328
|
+
test("replay (used invite) → 410", async () => {
|
|
329
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
330
|
+
const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "x" });
|
|
331
|
+
consumeInvite(harness.db, invite.tokenHash, admin.id);
|
|
332
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
333
|
+
const res = await handleAccountSetupPost(
|
|
334
|
+
postReq(
|
|
335
|
+
rawToken,
|
|
336
|
+
{
|
|
337
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
338
|
+
username: "late",
|
|
339
|
+
password: "late-strong-password-1",
|
|
340
|
+
password_confirm: "late-strong-password-1",
|
|
341
|
+
},
|
|
342
|
+
cookieFragment,
|
|
343
|
+
),
|
|
344
|
+
rawToken,
|
|
345
|
+
deps(makeStubRunCommand().run),
|
|
346
|
+
);
|
|
347
|
+
expect(res.status).toBe(410);
|
|
348
|
+
// No new user created.
|
|
349
|
+
expect(getUserByUsernameCI(harness.db, "late")).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("expired invite → 410", async () => {
|
|
353
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
354
|
+
const now = new Date("2026-06-04T00:00:00Z");
|
|
355
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
356
|
+
createdBy: admin.id,
|
|
357
|
+
vaultName: "x",
|
|
358
|
+
expiresInSeconds: 60,
|
|
359
|
+
now: () => now,
|
|
360
|
+
});
|
|
361
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
362
|
+
const later = new Date(now.getTime() + 120_000);
|
|
363
|
+
const res = await handleAccountSetupPost(
|
|
364
|
+
postReq(
|
|
365
|
+
rawToken,
|
|
366
|
+
{
|
|
367
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
368
|
+
username: "late",
|
|
369
|
+
password: "late-strong-password-1",
|
|
370
|
+
password_confirm: "late-strong-password-1",
|
|
371
|
+
},
|
|
372
|
+
cookieFragment,
|
|
373
|
+
),
|
|
374
|
+
rawToken,
|
|
375
|
+
{ ...deps(makeStubRunCommand().run), now: () => later },
|
|
376
|
+
);
|
|
377
|
+
expect(res.status).toBe(410);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("revoked invite → 410", async () => {
|
|
381
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
382
|
+
const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "x" });
|
|
383
|
+
revokeInvite(harness.db, invite.tokenHash);
|
|
384
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
385
|
+
const res = await handleAccountSetupPost(
|
|
386
|
+
postReq(
|
|
387
|
+
rawToken,
|
|
388
|
+
{
|
|
389
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
390
|
+
username: "late",
|
|
391
|
+
password: "late-strong-password-1",
|
|
392
|
+
password_confirm: "late-strong-password-1",
|
|
393
|
+
},
|
|
394
|
+
cookieFragment,
|
|
395
|
+
),
|
|
396
|
+
rawToken,
|
|
397
|
+
deps(makeStubRunCommand().run),
|
|
398
|
+
);
|
|
399
|
+
expect(res.status).toBe(410);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("tampered/unknown token → 404", async () => {
|
|
403
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
404
|
+
const res = await handleAccountSetupPost(
|
|
405
|
+
postReq(
|
|
406
|
+
"unknown-token",
|
|
407
|
+
{
|
|
408
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
409
|
+
username: "x",
|
|
410
|
+
password: "xxxxxxxxxxxx",
|
|
411
|
+
password_confirm: "xxxxxxxxxxxx",
|
|
412
|
+
},
|
|
413
|
+
cookieFragment,
|
|
414
|
+
),
|
|
415
|
+
"unknown-token",
|
|
416
|
+
deps(makeStubRunCommand().run),
|
|
417
|
+
);
|
|
418
|
+
expect(res.status).toBe(404);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("missing CSRF → 400, invite untouched", async () => {
|
|
422
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
423
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "x" });
|
|
424
|
+
const res = await handleAccountSetupPost(
|
|
425
|
+
// No CSRF cookie/field.
|
|
426
|
+
new Request(`${ISSUER}/account/setup/${rawToken}`, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
429
|
+
body: new URLSearchParams({
|
|
430
|
+
username: "x",
|
|
431
|
+
password: "xxxxxxxxxxxx",
|
|
432
|
+
password_confirm: "xxxxxxxxxxxx",
|
|
433
|
+
}).toString(),
|
|
434
|
+
}),
|
|
435
|
+
rawToken,
|
|
436
|
+
deps(makeStubRunCommand().run),
|
|
437
|
+
);
|
|
438
|
+
expect(res.status).toBe(400);
|
|
439
|
+
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("POST /account/setup/<token> — re-usability on createUser failure (ordering)", () => {
|
|
444
|
+
test("createUser collision leaves the invite UNCONSUMED (re-usable)", async () => {
|
|
445
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
446
|
+
// Pre-create a user with the username the invitee will pick → createUser
|
|
447
|
+
// raises UsernameTakenError, AFTER provisionVault has run.
|
|
448
|
+
await createUser(harness.db, "taken", "taken-password-12", { allowMulti: true });
|
|
449
|
+
|
|
450
|
+
const { rawToken, invite } = issueInvite(harness.db, {
|
|
451
|
+
createdBy: admin.id,
|
|
452
|
+
vaultName: "maya",
|
|
453
|
+
});
|
|
454
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
455
|
+
const res = await handleAccountSetupPost(
|
|
456
|
+
postReq(
|
|
457
|
+
rawToken,
|
|
458
|
+
{
|
|
459
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
460
|
+
username: "taken",
|
|
461
|
+
password: "another-strong-password-1",
|
|
462
|
+
password_confirm: "another-strong-password-1",
|
|
463
|
+
},
|
|
464
|
+
cookieFragment,
|
|
465
|
+
),
|
|
466
|
+
rawToken,
|
|
467
|
+
deps(makeStubRunCommand().run),
|
|
468
|
+
);
|
|
469
|
+
expect(res.status).toBe(409);
|
|
470
|
+
// The invite must NOT be consumed — the ordering guarantee.
|
|
471
|
+
const after = findInviteByRawToken(harness.db, rawToken);
|
|
472
|
+
expect(after?.usedAt).toBeNull();
|
|
473
|
+
expect(after?.redeemedUserId).toBeNull();
|
|
474
|
+
|
|
475
|
+
// And a SECOND attempt with a free username succeeds + consumes it.
|
|
476
|
+
__resetForTests();
|
|
477
|
+
const second = csrfPair();
|
|
478
|
+
const res2 = await handleAccountSetupPost(
|
|
479
|
+
postReq(
|
|
480
|
+
rawToken,
|
|
481
|
+
{
|
|
482
|
+
[CSRF_FIELD_NAME]: second.token,
|
|
483
|
+
username: "maya",
|
|
484
|
+
password: "maya-strong-password-12",
|
|
485
|
+
password_confirm: "maya-strong-password-12",
|
|
486
|
+
},
|
|
487
|
+
second.cookieFragment,
|
|
488
|
+
),
|
|
489
|
+
rawToken,
|
|
490
|
+
deps(makeStubRunCommand().run),
|
|
491
|
+
);
|
|
492
|
+
expect(res2.status).toBe(302);
|
|
493
|
+
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).not.toBeNull();
|
|
494
|
+
void invite;
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe("POST /account/setup/<token> — vault-name validation (N1)", () => {
|
|
499
|
+
test("a 33-char invitee-chosen vault name → clear validation error, NOT a generic provision failure", async () => {
|
|
500
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
501
|
+
// vault_name null → the redeemer names their own vault.
|
|
502
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
|
|
503
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
504
|
+
const tooLong = "a".repeat(33); // passes the bare charset regex, exceeds the 32 cap
|
|
505
|
+
const stub = makeStubRunCommand();
|
|
506
|
+
const res = await handleAccountSetupPost(
|
|
507
|
+
postReq(
|
|
508
|
+
rawToken,
|
|
509
|
+
{
|
|
510
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
511
|
+
username: "sam",
|
|
512
|
+
password: "sam-strong-password-12",
|
|
513
|
+
password_confirm: "sam-strong-password-12",
|
|
514
|
+
vault_name: tooLong,
|
|
515
|
+
},
|
|
516
|
+
cookieFragment,
|
|
517
|
+
),
|
|
518
|
+
rawToken,
|
|
519
|
+
deps(stub.run),
|
|
520
|
+
);
|
|
521
|
+
// 400 with the validator's specific message — the vault CLI is never reached.
|
|
522
|
+
expect(res.status).toBe(400);
|
|
523
|
+
const html = await res.text();
|
|
524
|
+
expect(html).toContain("2–32 characters");
|
|
525
|
+
expect(html).not.toContain("Could not provision your vault");
|
|
526
|
+
expect(stub.calls.length).toBe(0);
|
|
527
|
+
// No account created.
|
|
528
|
+
expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("POST /account/setup/<token> — concurrent redeem (N2)", () => {
|
|
533
|
+
test("two concurrent redeems of one invite create EXACTLY one account (no orphan)", async () => {
|
|
534
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
535
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
|
|
536
|
+
const before = userCount(harness.db); // 1 (the admin)
|
|
537
|
+
|
|
538
|
+
// Two POSTs with DIFFERENT usernames, fired together. Each has its own
|
|
539
|
+
// CSRF pair. createUser awaits argon2 before its (synchronous) commit
|
|
540
|
+
// transaction; the consume-inside-tx guard is what serializes them.
|
|
541
|
+
const a = csrfPair();
|
|
542
|
+
const b = csrfPair();
|
|
543
|
+
const mk = (uname: string, csrf: { token: string; cookieFragment: string }) =>
|
|
544
|
+
handleAccountSetupPost(
|
|
545
|
+
postReq(
|
|
546
|
+
rawToken,
|
|
547
|
+
{
|
|
548
|
+
[CSRF_FIELD_NAME]: csrf.token,
|
|
549
|
+
username: uname,
|
|
550
|
+
password: `${uname}-strong-password-1`,
|
|
551
|
+
password_confirm: `${uname}-strong-password-1`,
|
|
552
|
+
},
|
|
553
|
+
csrf.cookieFragment,
|
|
554
|
+
),
|
|
555
|
+
rawToken,
|
|
556
|
+
deps(makeStubRunCommand().run),
|
|
557
|
+
);
|
|
558
|
+
const [r1, r2] = await Promise.all([mk("alice", a), mk("bob", b)]);
|
|
559
|
+
|
|
560
|
+
const statuses = [r1.status, r2.status].sort();
|
|
561
|
+
// Exactly one 302 (success) and one 410 (the loser's used-path).
|
|
562
|
+
expect(statuses).toEqual([302, 410]);
|
|
563
|
+
// EXACTLY one account was created from the invite — no orphan row.
|
|
564
|
+
expect(userCount(harness.db) - before).toBe(1);
|
|
565
|
+
const aliceExists = getUserByUsernameCI(harness.db, "alice") !== null;
|
|
566
|
+
const bobExists = getUserByUsernameCI(harness.db, "bob") !== null;
|
|
567
|
+
// Exactly one of the two usernames landed.
|
|
568
|
+
expect(aliceExists !== bobExists).toBe(true);
|
|
569
|
+
// The invite is consumed, pinned to whichever user won.
|
|
570
|
+
const after = findInviteByRawToken(harness.db, rawToken);
|
|
571
|
+
expect(after?.usedAt).not.toBeNull();
|
|
572
|
+
const winner = getUserByUsernameCI(harness.db, aliceExists ? "alice" : "bob");
|
|
573
|
+
expect(after?.redeemedUserId).toBe(winner?.id ?? "");
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe("POST /account/setup/<token> — account-only invite (N3)", () => {
|
|
578
|
+
test("provision_vault=false, no vault_name → user with empty assignedVaults, no vault shell-out", async () => {
|
|
579
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
580
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
581
|
+
createdBy: admin.id,
|
|
582
|
+
provisionVault: false,
|
|
583
|
+
});
|
|
584
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
585
|
+
const stub = makeStubRunCommand();
|
|
586
|
+
const res = await handleAccountSetupPost(
|
|
587
|
+
postReq(
|
|
588
|
+
rawToken,
|
|
589
|
+
{
|
|
590
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
591
|
+
username: "accountonly",
|
|
592
|
+
password: "accountonly-password-1",
|
|
593
|
+
password_confirm: "accountonly-password-1",
|
|
594
|
+
},
|
|
595
|
+
cookieFragment,
|
|
596
|
+
),
|
|
597
|
+
rawToken,
|
|
598
|
+
deps(stub.run),
|
|
599
|
+
);
|
|
600
|
+
expect(res.status).toBe(302);
|
|
601
|
+
const user = getUserByUsernameCI(harness.db, "accountonly");
|
|
602
|
+
expect(user).not.toBeNull();
|
|
603
|
+
expect(user?.assignedVaults).toEqual([]);
|
|
604
|
+
// No vault provisioning shell-out for an account-only invite.
|
|
605
|
+
expect(stub.calls.length).toBe(0);
|
|
606
|
+
// Invite consumed.
|
|
607
|
+
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).not.toBeNull();
|
|
608
|
+
});
|
|
609
|
+
});
|