@openparachute/hub 0.7.0 → 0.7.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.
@@ -68,6 +68,24 @@ function deps() {
68
68
  return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
69
69
  }
70
70
 
71
+ /** Register an existing vault in the harness manifest (for shared-vault invites). */
72
+ function seedVaultInManifest(name: string) {
73
+ writeFileSync(
74
+ harness.manifestPath,
75
+ JSON.stringify({
76
+ services: [
77
+ {
78
+ name: "parachute-vault",
79
+ port: 4101,
80
+ paths: [`/vault/${name}`],
81
+ health: "/health",
82
+ version: "0.0.0-test",
83
+ },
84
+ ],
85
+ }),
86
+ );
87
+ }
88
+
71
89
  function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
72
90
  const headers = new Headers(init.headers ?? {});
73
91
  headers.set("authorization", `Bearer ${bearer}`);
@@ -121,23 +139,64 @@ describe("POST /api/invites", () => {
121
139
  expect(res.status).toBe(400);
122
140
  });
123
141
 
124
- test("400 rejects a shared-vault invite (provision_vault=false + vault_name)", async () => {
125
- // Defense in depth (FIX-1): assigning a redeemer to a PRE-EXISTING vault
126
- // as owner-admin is a cross-tenant breach; shared-vault invites aren't
127
- // supported yet, so the create handler refuses this combination outright.
142
+ test("shared-vault invite (provision_vault=false + EXISTING vault_name) → 201 at the given role", async () => {
143
+ // The Adam/Jonathan shape: read-only access to a vault the admin already
144
+ // built. Issuing is host:admin-gated the same authority that can assign
145
+ // any user to any vault via POST /api/users.
146
+ seedVaultInManifest("jonathan-vault");
128
147
  const bearer = await makeAdminBearer();
129
148
  const res = await handleCreateInvite(
130
149
  withBearer("/api/invites", bearer, {
131
150
  method: "POST",
132
151
  headers: { "content-type": "application/json" },
133
- body: JSON.stringify({ provision_vault: false, vault_name: "someoneelse" }),
152
+ body: JSON.stringify({
153
+ provision_vault: false,
154
+ vault_name: "jonathan-vault",
155
+ role: "read",
156
+ }),
157
+ }),
158
+ deps(),
159
+ );
160
+ expect(res.status).toBe(201);
161
+ const body = (await res.json()) as {
162
+ invite: { vault_name: string | null; role: string; provision_vault: boolean };
163
+ };
164
+ expect(body.invite.vault_name).toBe("jonathan-vault");
165
+ expect(body.invite.role).toBe("read");
166
+ expect(body.invite.provision_vault).toBe(false);
167
+ });
168
+
169
+ test("400 vault_not_found when the shared vault doesn't exist on this hub", async () => {
170
+ // A pinned-but-nonexistent name is a typo, not a future reservation —
171
+ // fail at mint, not at the invitee's redeem.
172
+ const bearer = await makeAdminBearer();
173
+ const res = await handleCreateInvite(
174
+ withBearer("/api/invites", bearer, {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify({ provision_vault: false, vault_name: "nosuchvault", role: "read" }),
178
+ }),
179
+ deps(),
180
+ );
181
+ expect(res.status).toBe(400);
182
+ const body = (await res.json()) as { error: string };
183
+ expect(body.error).toBe("vault_not_found");
184
+ });
185
+
186
+ test("400 rejects role=read with provision_vault=true (sole user of a new vault must hold write)", async () => {
187
+ const bearer = await makeAdminBearer();
188
+ const res = await handleCreateInvite(
189
+ withBearer("/api/invites", bearer, {
190
+ method: "POST",
191
+ headers: { "content-type": "application/json" },
192
+ body: JSON.stringify({ provision_vault: true, role: "read" }),
134
193
  }),
135
194
  deps(),
136
195
  );
137
196
  expect(res.status).toBe(400);
138
197
  const body = (await res.json()) as { error: string; error_description: string };
139
198
  expect(body.error).toBe("invalid_request");
140
- expect(body.error_description).toContain("shared-vault");
199
+ expect(body.error_description).toContain("write");
141
200
  });
142
201
 
143
202
  test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
@@ -159,6 +218,107 @@ describe("POST /api/invites", () => {
159
218
  });
160
219
  });
161
220
 
221
+ describe("POST /api/invites — pre-named username", () => {
222
+ test("201 carries the username in the wire shape", async () => {
223
+ seedVaultInManifest("jonathan-vault");
224
+ const bearer = await makeAdminBearer();
225
+ const res = await handleCreateInvite(
226
+ withBearer("/api/invites", bearer, {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify({
230
+ username: "jonathan",
231
+ provision_vault: false,
232
+ vault_name: "jonathan-vault",
233
+ role: "read",
234
+ }),
235
+ }),
236
+ deps(),
237
+ );
238
+ expect(res.status).toBe(201);
239
+ const body = (await res.json()) as { invite: { username: string | null } };
240
+ expect(body.invite.username).toBe("jonathan");
241
+ });
242
+
243
+ test("400 invalid_username on a name outside the /api/users vocabulary", async () => {
244
+ const bearer = await makeAdminBearer();
245
+ for (const bad of ["A", "Has Spaces", "admin"]) {
246
+ const res = await handleCreateInvite(
247
+ withBearer("/api/invites", bearer, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({ username: bad }),
251
+ }),
252
+ deps(),
253
+ );
254
+ expect(res.status).toBe(400);
255
+ const body = (await res.json()) as { error: string };
256
+ expect(body.error).toBe("invalid_username");
257
+ }
258
+ });
259
+
260
+ test("409 username_taken when an existing user holds the name (case-insensitive)", async () => {
261
+ const bearer = await makeAdminBearer(); // creates user "operator"
262
+ const res = await handleCreateInvite(
263
+ withBearer("/api/invites", bearer, {
264
+ method: "POST",
265
+ headers: { "content-type": "application/json" },
266
+ body: JSON.stringify({ username: "operator" }),
267
+ }),
268
+ deps(),
269
+ );
270
+ expect(res.status).toBe(409);
271
+ const body = (await res.json()) as { error: string };
272
+ expect(body.error).toBe("username_taken");
273
+ });
274
+
275
+ test("409 username_reserved when another PENDING invite pre-names the same username", async () => {
276
+ const bearer = await makeAdminBearer();
277
+ const make = () =>
278
+ handleCreateInvite(
279
+ withBearer("/api/invites", bearer, {
280
+ method: "POST",
281
+ headers: { "content-type": "application/json" },
282
+ body: JSON.stringify({ username: "jonathan" }),
283
+ }),
284
+ deps(),
285
+ );
286
+ const first = await make();
287
+ expect(first.status).toBe(201);
288
+ const second = await make();
289
+ expect(second.status).toBe(409);
290
+ const body = (await second.json()) as { error: string };
291
+ expect(body.error).toBe("username_reserved");
292
+ });
293
+
294
+ test("revoking the pending invite frees the reserved username", async () => {
295
+ const bearer = await makeAdminBearer();
296
+ const first = await handleCreateInvite(
297
+ withBearer("/api/invites", bearer, {
298
+ method: "POST",
299
+ headers: { "content-type": "application/json" },
300
+ body: JSON.stringify({ username: "jonathan" }),
301
+ }),
302
+ deps(),
303
+ );
304
+ const { invite } = (await first.json()) as { invite: { id: string } };
305
+ await handleRevokeInvite(
306
+ withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
307
+ invite.id,
308
+ deps(),
309
+ );
310
+ const second = await handleCreateInvite(
311
+ withBearer("/api/invites", bearer, {
312
+ method: "POST",
313
+ headers: { "content-type": "application/json" },
314
+ body: JSON.stringify({ username: "jonathan" }),
315
+ }),
316
+ deps(),
317
+ );
318
+ expect(second.status).toBe(201);
319
+ });
320
+ });
321
+
162
322
  describe("GET /api/invites", () => {
163
323
  test("lists invites; raw token NEVER present in the wire shape", async () => {
164
324
  const bearer = await makeAdminBearer();