@udibo/oauth2 0.7.0 → 0.8.0
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/esm/_dnt.polyfills.d.ts +14 -0
- package/esm/_dnt.polyfills.js +11 -0
- package/esm/adapters/hono/bff/bff.d.ts +43 -1
- package/esm/adapters/hono/bff/bff.js +67 -9
- package/esm/testing/contract/mod.d.ts +1 -1
- package/esm/testing/contract/tenant.d.ts +49 -9
- package/esm/testing/contract/tenant.js +631 -5
- package/esm/testing/mod.d.ts +6 -5
- package/esm/testing/mod.js +5 -4
- package/esm/testing/tenant.d.ts +88 -16
- package/esm/testing/tenant.js +971 -57
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contract test suite for a Udibo Identity tenant
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* answer
|
|
2
|
+
* Contract test suite for what a Udibo Identity tenant answers an application
|
|
3
|
+
* about its signed-in people: what introspection reports, what
|
|
4
|
+
* `GET /api/memberships` lists, what `POST /api/check` and
|
|
5
|
+
* `POST /api/check/batch` answer, the organization API under
|
|
6
|
+
* `/api/organizations`, and the account API under `/api/account`.
|
|
6
7
|
*
|
|
7
8
|
* It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
|
|
8
9
|
* real identity service in Udibo's own repository, so the fake an app tests
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
*
|
|
24
25
|
* @module
|
|
25
26
|
*/
|
|
26
|
-
import { assert, assertEquals, assertFalse } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
|
|
27
|
+
import { assert, assertEquals, assertFalse, assertStringIncludes, } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
|
|
27
28
|
import { afterAll, beforeAll, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
|
|
28
29
|
import { encodeBasicAuth } from "../../utils/basic-auth.js";
|
|
29
30
|
/**
|
|
@@ -305,5 +306,630 @@ export function runTenantContractTests(options) {
|
|
|
305
306
|
assert(answer.status === 400, `answered ${answer.status}`);
|
|
306
307
|
});
|
|
307
308
|
});
|
|
309
|
+
async function call(accessToken, method, path, body) {
|
|
310
|
+
const headers = accessToken
|
|
311
|
+
? { authorization: `Bearer ${accessToken}` }
|
|
312
|
+
: {};
|
|
313
|
+
if (body !== undefined)
|
|
314
|
+
headers["content-type"] = "application/json";
|
|
315
|
+
const response = await send(new URL(path, tenant.issuer), {
|
|
316
|
+
method,
|
|
317
|
+
headers,
|
|
318
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
319
|
+
});
|
|
320
|
+
const text = await response.text();
|
|
321
|
+
return {
|
|
322
|
+
status: response.status,
|
|
323
|
+
headers: response.headers,
|
|
324
|
+
body: (text ? JSON.parse(text) : null),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const newEmail = () => `contract-${crypto.randomUUID()}@example.com`;
|
|
328
|
+
const newSlug = () => `contract-${crypto.randomUUID().slice(0, 13)}`;
|
|
329
|
+
async function person(profile = {}) {
|
|
330
|
+
const email = profile.email ?? newEmail();
|
|
331
|
+
const id = await tenant.addUser([], { ...profile, email });
|
|
332
|
+
const { access_token } = await tenant.signIn(id);
|
|
333
|
+
return { id, email, token: access_token };
|
|
334
|
+
}
|
|
335
|
+
async function membershipsOf(accessToken) {
|
|
336
|
+
const reply = await call(accessToken, "GET", "/api/memberships");
|
|
337
|
+
assertEquals(reply.status, 200);
|
|
338
|
+
return reply.body.memberships;
|
|
339
|
+
}
|
|
340
|
+
async function createOrganization(accessToken) {
|
|
341
|
+
const slug = newSlug();
|
|
342
|
+
const reply = await call(accessToken, "POST", "/api/organizations", { name: `Contract ${slug}`, slug });
|
|
343
|
+
assertEquals(reply.status, 201, JSON.stringify(reply.body));
|
|
344
|
+
return reply.body;
|
|
345
|
+
}
|
|
346
|
+
async function invite(accessToken, organizationId, email, role) {
|
|
347
|
+
return await call(accessToken, "POST", `/api/organizations/${organizationId}/invitations`, { email, role });
|
|
348
|
+
}
|
|
349
|
+
async function accept(accessToken, offerId) {
|
|
350
|
+
const reply = await call(accessToken, "POST", `/api/organizations/offers/${offerId}/accept`);
|
|
351
|
+
assertEquals(reply.status, 200, JSON.stringify(reply.body));
|
|
352
|
+
return reply.body;
|
|
353
|
+
}
|
|
354
|
+
async function seat(managerToken, organizationId, invitee, role) {
|
|
355
|
+
const offered = await invite(managerToken, organizationId, invitee.email, role);
|
|
356
|
+
assertEquals(offered.status, 201, JSON.stringify(offered.body));
|
|
357
|
+
assert(offered.body.status === "membership", offered.body.status);
|
|
358
|
+
const accepted = await accept(invitee.token, offered.body.membership.id);
|
|
359
|
+
assertEquals(accepted.status, "accepted");
|
|
360
|
+
}
|
|
361
|
+
async function membersOf(accessToken, organizationId) {
|
|
362
|
+
return await call(accessToken, "GET", `/api/organizations/${organizationId}/members`);
|
|
363
|
+
}
|
|
364
|
+
describe("the organization API", () => {
|
|
365
|
+
const seats = {};
|
|
366
|
+
let organization;
|
|
367
|
+
let rival;
|
|
368
|
+
let rivalInvitationId;
|
|
369
|
+
beforeAll(async () => {
|
|
370
|
+
seats.owner = await person();
|
|
371
|
+
seats.admin = await person();
|
|
372
|
+
seats.member = await person();
|
|
373
|
+
seats.outsider = await person();
|
|
374
|
+
seats.rival = await person();
|
|
375
|
+
seats.rivalMember = await person();
|
|
376
|
+
organization = await createOrganization(seats.owner.token);
|
|
377
|
+
await seat(seats.owner.token, organization.id, seats.admin, "admin");
|
|
378
|
+
await seat(seats.owner.token, organization.id, seats.member, "member");
|
|
379
|
+
rival = await createOrganization(seats.rival.token);
|
|
380
|
+
await seat(seats.rival.token, rival.id, seats.rivalMember, "member");
|
|
381
|
+
const rivalOffer = await invite(seats.rival.token, rival.id, newEmail(), "member");
|
|
382
|
+
assert(rivalOffer.body.status === "invitation", rivalOffer.body.status);
|
|
383
|
+
rivalInvitationId = rivalOffer.body.invitation.id;
|
|
384
|
+
});
|
|
385
|
+
async function invitationIdsOf(accessToken, organizationId) {
|
|
386
|
+
const listed = await call(accessToken, "GET", `/api/organizations/${organizationId}/invitations`);
|
|
387
|
+
assertEquals(listed.status, 200);
|
|
388
|
+
return listed.body.data.map((entry) => entry.id);
|
|
389
|
+
}
|
|
390
|
+
it("creates an organization its creator owns", async () => {
|
|
391
|
+
const creator = await person();
|
|
392
|
+
const slug = newSlug();
|
|
393
|
+
const created = await call(creator.token, "POST", "/api/organizations", { name: "Contract Created", slug });
|
|
394
|
+
assertEquals(created.status, 201);
|
|
395
|
+
assertEquals(created.body.slug, slug);
|
|
396
|
+
assertEquals(created.body.name, "Contract Created");
|
|
397
|
+
const listed = await call(creator.token, "GET", "/api/organizations");
|
|
398
|
+
assertEquals(listed.status, 200);
|
|
399
|
+
assertEquals(listed.body.data.map((entry) => entry.id), [created.body.id]);
|
|
400
|
+
assertEquals(await membershipsOf(creator.token), [{
|
|
401
|
+
org_id: created.body.id,
|
|
402
|
+
org_slug: slug,
|
|
403
|
+
name: "Contract Created",
|
|
404
|
+
roles: ["owner"],
|
|
405
|
+
}]);
|
|
406
|
+
});
|
|
407
|
+
it("refuses a slug another organization holds", async () => {
|
|
408
|
+
const reply = await call(seats.outsider.token, "POST", "/api/organizations", { name: "Contract Taken", slug: organization.slug });
|
|
409
|
+
assertEquals(reply.status, 409);
|
|
410
|
+
});
|
|
411
|
+
it("refuses a slug that is not lowercase letters, numbers and single hyphens", async () => {
|
|
412
|
+
const reply = await call(seats.outsider.token, "POST", "/api/organizations", { name: "Contract Shouting", slug: "Contract--Shouting" });
|
|
413
|
+
assertEquals(reply.status, 400);
|
|
414
|
+
});
|
|
415
|
+
it("lets a member of any tier read the organization and its members, and no cache keep them", async () => {
|
|
416
|
+
const read = await call(seats.member.token, "GET", `/api/organizations/${organization.id}`);
|
|
417
|
+
assertEquals(read.status, 200);
|
|
418
|
+
assertEquals(read.body.id, organization.id);
|
|
419
|
+
assertEquals(read.body.slug, organization.slug);
|
|
420
|
+
assertStringIncludes(read.headers.get("cache-control") ?? "", "no-store");
|
|
421
|
+
const members = await membersOf(seats.member.token, organization.id);
|
|
422
|
+
assertEquals(members.status, 200);
|
|
423
|
+
assertStringIncludes(members.headers.get("cache-control") ?? "", "no-store");
|
|
424
|
+
assertEquals(members.body.data.map((row) => `${row.userId}:${row.role}`).sort(), [
|
|
425
|
+
`${seats.admin.id}:admin`,
|
|
426
|
+
`${seats.member.id}:member`,
|
|
427
|
+
`${seats.owner.id}:owner`,
|
|
428
|
+
].sort());
|
|
429
|
+
assertFalse(members.body.hasMore);
|
|
430
|
+
for (const row of members.body.data) {
|
|
431
|
+
assert(row.id, "every member row names its grant");
|
|
432
|
+
assert(row.name, "every member row names its person");
|
|
433
|
+
assert(row.acceptedAt, "a member row is an accepted grant");
|
|
434
|
+
}
|
|
435
|
+
assertEquals(members.body.data.find((row) => row.userId === seats.member.id)
|
|
436
|
+
?.email, seats.member.email);
|
|
437
|
+
});
|
|
438
|
+
it("answers a non-member, an unknown id and a malformed id with the same 404", async () => {
|
|
439
|
+
const outsider = await call(seats.outsider.token, "GET", `/api/organizations/${organization.id}`);
|
|
440
|
+
const unknown = await call(seats.owner.token, "GET", `/api/organizations/${crypto.randomUUID()}`);
|
|
441
|
+
const malformed = await call(seats.owner.token, "GET", "/api/organizations/not-an-organization");
|
|
442
|
+
assertEquals([outsider.status, unknown.status, malformed.status], [404, 404, 404]);
|
|
443
|
+
assertEquals(outsider.body.detail, unknown.body.detail);
|
|
444
|
+
assertEquals(unknown.body.detail, malformed.body.detail);
|
|
445
|
+
const members = await membersOf(seats.outsider.token, organization.id);
|
|
446
|
+
assertEquals(members.status, 404);
|
|
447
|
+
});
|
|
448
|
+
it("lets an owner or admin rename the organization and a plain member not", async () => {
|
|
449
|
+
const renamed = await call(seats.admin.token, "PATCH", `/api/organizations/${organization.id}`, { name: "Contract Renamed" });
|
|
450
|
+
assertEquals(renamed.status, 200);
|
|
451
|
+
assertEquals(renamed.body.name, "Contract Renamed");
|
|
452
|
+
assertEquals(renamed.body.slug, organization.slug);
|
|
453
|
+
for (const caller of [seats.member, seats.outsider]) {
|
|
454
|
+
const refused = await call(caller.token, "PATCH", `/api/organizations/${organization.id}`, { name: "Contract Hijacked" });
|
|
455
|
+
assertEquals(refused.status, 404);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
it("lists the membership tiers an invitation may offer, to a manager only", async () => {
|
|
459
|
+
const roles = await call(seats.admin.token, "GET", `/api/organizations/${organization.id}/member-roles`);
|
|
460
|
+
assertEquals(roles.status, 200);
|
|
461
|
+
assertEquals(roles.body.slice(0, 3), [
|
|
462
|
+
{ slug: "owner", name: "Owner" },
|
|
463
|
+
{ slug: "admin", name: "Admin" },
|
|
464
|
+
{ slug: "member", name: "Member" },
|
|
465
|
+
]);
|
|
466
|
+
const refused = await call(seats.member.token, "GET", `/api/organizations/${organization.id}/member-roles`);
|
|
467
|
+
assertEquals(refused.status, 404);
|
|
468
|
+
});
|
|
469
|
+
it("offers an existing person a pending membership that confers nothing until they accept it", async () => {
|
|
470
|
+
const invitee = await person();
|
|
471
|
+
const offered = await invite(seats.admin.token, organization.id, invitee.email, "member");
|
|
472
|
+
assertEquals(offered.status, 201);
|
|
473
|
+
assert(offered.body.status === "membership", offered.body.status);
|
|
474
|
+
assertEquals(offered.body.membership.role, "member");
|
|
475
|
+
assertEquals(offered.body.membership.acceptedAt, null);
|
|
476
|
+
assertEquals(await membershipsOf(invitee.token), []);
|
|
477
|
+
const before = await membersOf(seats.admin.token, organization.id);
|
|
478
|
+
assertFalse(before.body.data.some((row) => row.userId === invitee.id), "a pending grant is not a member");
|
|
479
|
+
const waiting = await call(invitee.token, "GET", "/api/organizations/offers");
|
|
480
|
+
assertEquals(waiting.status, 200);
|
|
481
|
+
const current = await call(seats.owner.token, "GET", `/api/organizations/${organization.id}`);
|
|
482
|
+
assertEquals(waiting.body, {
|
|
483
|
+
offers: [{
|
|
484
|
+
id: offered.body.membership.id,
|
|
485
|
+
organizationName: current.body.name,
|
|
486
|
+
roleName: "Member",
|
|
487
|
+
}],
|
|
488
|
+
unverifiedEmail: null,
|
|
489
|
+
});
|
|
490
|
+
const accepted = await accept(invitee.token, offered.body.membership.id);
|
|
491
|
+
assertEquals(accepted.status, "accepted");
|
|
492
|
+
assertEquals(accepted.membership?.organizationId, organization.id);
|
|
493
|
+
assertEquals(accepted.membership?.role, "member");
|
|
494
|
+
assertEquals((await membershipsOf(invitee.token)).map((entry) => entry.roles), [["member"]]);
|
|
495
|
+
const after = await membersOf(seats.admin.token, organization.id);
|
|
496
|
+
assert(after.body.data.some((row) => row.userId === invitee.id));
|
|
497
|
+
});
|
|
498
|
+
it("answers an offer the caller cannot accept as invalid, and leaves it unspent", async () => {
|
|
499
|
+
const invitee = await person();
|
|
500
|
+
const offered = await invite(seats.owner.token, organization.id, invitee.email, "member");
|
|
501
|
+
assert(offered.body.status === "membership", offered.body.status);
|
|
502
|
+
const offerId = offered.body.membership.id;
|
|
503
|
+
assertEquals((await accept(seats.outsider.token, offerId)).status, "invalid");
|
|
504
|
+
assertEquals((await accept(seats.outsider.token, "not-an-offer")).status, "invalid");
|
|
505
|
+
assertEquals((await accept(invitee.token, offerId)).status, "accepted");
|
|
506
|
+
});
|
|
507
|
+
it("refuses a second offer of a role an address already holds or was offered", async () => {
|
|
508
|
+
const stranger = newEmail();
|
|
509
|
+
const first = await invite(seats.admin.token, organization.id, stranger, "member");
|
|
510
|
+
assertEquals(first.status, 201);
|
|
511
|
+
const again = await invite(seats.admin.token, organization.id, stranger, "member");
|
|
512
|
+
assertEquals(again.status, 409);
|
|
513
|
+
const held = await invite(seats.admin.token, organization.id, seats.member.email, "member");
|
|
514
|
+
assertEquals(held.status, 409);
|
|
515
|
+
});
|
|
516
|
+
it("keeps the owner tier to owners", async () => {
|
|
517
|
+
const offered = await invite(seats.admin.token, organization.id, newEmail(), "owner");
|
|
518
|
+
assertEquals(offered.status, 403);
|
|
519
|
+
const revoked = await call(seats.admin.token, "DELETE", `/api/organizations/${organization.id}/members/${seats.owner.id}/owner`);
|
|
520
|
+
assertEquals(revoked.status, 403);
|
|
521
|
+
});
|
|
522
|
+
it("refuses a plain member every manager action with the 404 a stranger gets, and changes nothing", async () => {
|
|
523
|
+
const pending = await invite(seats.admin.token, organization.id, newEmail(), "member");
|
|
524
|
+
assert(pending.body.status === "invitation", pending.body.status);
|
|
525
|
+
const pendingId = pending.body.invitation.id;
|
|
526
|
+
const base = `/api/organizations/${organization.id}`;
|
|
527
|
+
const actions = [
|
|
528
|
+
["DELETE", `${base}/members/${seats.admin.id}/admin`],
|
|
529
|
+
["DELETE", `${base}/invitations/${pendingId}`],
|
|
530
|
+
["GET", `${base}/invitations`],
|
|
531
|
+
];
|
|
532
|
+
for (const [method, path] of actions) {
|
|
533
|
+
const byMember = await call(seats.member.token, method, path);
|
|
534
|
+
const byStranger = await call(seats.outsider.token, method, path);
|
|
535
|
+
assertEquals([byMember.status, byStranger.status], [404, 404], `${method} ${path}`);
|
|
536
|
+
assertEquals(byMember.body.detail, byStranger.body.detail);
|
|
537
|
+
}
|
|
538
|
+
const members = await membersOf(seats.owner.token, organization.id);
|
|
539
|
+
assert(members.body.data.some((row) => row.userId === seats.admin.id && row.role === "admin"), "a plain member revoked a manager's tier");
|
|
540
|
+
assert((await invitationIdsOf(seats.admin.token, organization.id)).includes(pendingId), "a plain member withdrew an invitation");
|
|
541
|
+
});
|
|
542
|
+
it("keeps a manager's actions to the organization the path names", async () => {
|
|
543
|
+
const base = `/api/organizations/${organization.id}`;
|
|
544
|
+
const withdrawn = await call(seats.owner.token, "DELETE", `${base}/invitations/${rivalInvitationId}`);
|
|
545
|
+
assertEquals(withdrawn.status, 404);
|
|
546
|
+
const revoked = await call(seats.owner.token, "DELETE", `${base}/members/${seats.rivalMember.id}/member`);
|
|
547
|
+
assertEquals(revoked.status, 404);
|
|
548
|
+
assertFalse((await invitationIdsOf(seats.owner.token, organization.id)).includes(rivalInvitationId), "one organization's invitations listed another's");
|
|
549
|
+
const rivalBase = `/api/organizations/${rival.id}`;
|
|
550
|
+
for (const [method, path] of [
|
|
551
|
+
["DELETE", `${rivalBase}/invitations/${rivalInvitationId}`],
|
|
552
|
+
["DELETE", `${rivalBase}/members/${seats.rivalMember.id}/member`],
|
|
553
|
+
["GET", `${rivalBase}/invitations`],
|
|
554
|
+
]) {
|
|
555
|
+
const refused = await call(seats.owner.token, method, path);
|
|
556
|
+
assertEquals(refused.status, 404, `${method} ${path}`);
|
|
557
|
+
}
|
|
558
|
+
assert((await invitationIdsOf(seats.rival.token, rival.id)).includes(rivalInvitationId), "another organization's manager withdrew this invitation");
|
|
559
|
+
const rivalMembers = await membersOf(seats.rival.token, rival.id);
|
|
560
|
+
assert(rivalMembers.body.data.some((row) => row.userId === seats.rivalMember.id), "another organization's manager revoked this member");
|
|
561
|
+
});
|
|
562
|
+
it("refuses an invitation from a plain member as it refuses a stranger", async () => {
|
|
563
|
+
const offered = await invite(seats.member.token, organization.id, newEmail(), "member");
|
|
564
|
+
assertEquals(offered.status, 404);
|
|
565
|
+
});
|
|
566
|
+
it("refuses a role the tenant has not defined", async () => {
|
|
567
|
+
const offered = await invite(seats.admin.token, organization.id, newEmail(), "contract-undefined-role");
|
|
568
|
+
assertEquals(offered.status, 400);
|
|
569
|
+
});
|
|
570
|
+
it("refuses a return_to on an origin with no registered redirect URI", async () => {
|
|
571
|
+
const reply = await call(seats.admin.token, "POST", `/api/organizations/${organization.id}/invitations`, {
|
|
572
|
+
email: newEmail(),
|
|
573
|
+
role: "member",
|
|
574
|
+
return_to: "https://unregistered.example/welcome",
|
|
575
|
+
});
|
|
576
|
+
assertEquals(reply.status, 400);
|
|
577
|
+
});
|
|
578
|
+
it("invites an address with no account, lowercased, for its verified holder to accept later", async () => {
|
|
579
|
+
const address = newEmail();
|
|
580
|
+
const offered = await invite(seats.admin.token, organization.id, address.toUpperCase(), "member");
|
|
581
|
+
assertEquals(offered.status, 201);
|
|
582
|
+
assert(offered.body.status === "invitation", offered.body.status);
|
|
583
|
+
assertEquals(offered.body.invitation.email, address);
|
|
584
|
+
const invitationId = offered.body.invitation.id;
|
|
585
|
+
const listed = await call(seats.admin.token, "GET", `/api/organizations/${organization.id}/invitations?status=outstanding`);
|
|
586
|
+
assertEquals(listed.status, 200);
|
|
587
|
+
assert(listed.body.data.some((entry) => entry.id === invitationId));
|
|
588
|
+
const holder = await person({ email: address });
|
|
589
|
+
const waiting = await call(holder.token, "GET", "/api/organizations/offers");
|
|
590
|
+
assertEquals(waiting.body.offers.map((offer) => offer.id), [invitationId]);
|
|
591
|
+
const accepted = await accept(holder.token, invitationId);
|
|
592
|
+
assertEquals(accepted.status, "accepted");
|
|
593
|
+
assertEquals(accepted.membership?.organizationId, organization.id);
|
|
594
|
+
assertEquals((await membershipsOf(holder.token)).map((entry) => entry.org_id), [organization.id]);
|
|
595
|
+
});
|
|
596
|
+
it("hides an invitation from its address's unverified holder and names the address instead", async () => {
|
|
597
|
+
const address = newEmail();
|
|
598
|
+
const offered = await invite(seats.admin.token, organization.id, address, "member");
|
|
599
|
+
assert(offered.body.status === "invitation", offered.body.status);
|
|
600
|
+
const holder = await person({ email: address, emailVerified: false });
|
|
601
|
+
const waiting = await call(holder.token, "GET", "/api/organizations/offers");
|
|
602
|
+
assertEquals(waiting.body, { offers: [], unverifiedEmail: address });
|
|
603
|
+
assertEquals((await accept(holder.token, offered.body.invitation.id)).status, "wrong-account");
|
|
604
|
+
});
|
|
605
|
+
it("converts an invitation only for the address it names", async () => {
|
|
606
|
+
const offered = await invite(seats.admin.token, organization.id, newEmail(), "member");
|
|
607
|
+
assert(offered.body.status === "invitation", offered.body.status);
|
|
608
|
+
const other = await person();
|
|
609
|
+
assertEquals((await accept(other.token, offered.body.invitation.id)).status, "wrong-account");
|
|
610
|
+
});
|
|
611
|
+
it("withdraws an invitation so that it can no longer be accepted", async () => {
|
|
612
|
+
const address = newEmail();
|
|
613
|
+
const offered = await invite(seats.admin.token, organization.id, address, "member");
|
|
614
|
+
assert(offered.body.status === "invitation", offered.body.status);
|
|
615
|
+
const invitationId = offered.body.invitation.id;
|
|
616
|
+
const withdrawn = await call(seats.admin.token, "DELETE", `/api/organizations/${organization.id}/invitations/${invitationId}`);
|
|
617
|
+
assertEquals(withdrawn.status, 204);
|
|
618
|
+
const listed = await call(seats.admin.token, "GET", `/api/organizations/${organization.id}/invitations`);
|
|
619
|
+
assertFalse(listed.body.data.some((entry) => entry.id === invitationId));
|
|
620
|
+
const holder = await person({ email: address });
|
|
621
|
+
assertEquals((await accept(holder.token, invitationId)).status, "invalid");
|
|
622
|
+
});
|
|
623
|
+
it("ends a member's access when a manager revokes their tier", async () => {
|
|
624
|
+
const leaver = await person();
|
|
625
|
+
await seat(seats.admin.token, organization.id, leaver, "member");
|
|
626
|
+
const revoked = await call(seats.admin.token, "DELETE", `/api/organizations/${organization.id}/members/${leaver.id}/member`);
|
|
627
|
+
assertEquals(revoked.status, 204);
|
|
628
|
+
assertEquals(await membershipsOf(leaver.token), []);
|
|
629
|
+
const read = await call(leaver.token, "GET", `/api/organizations/${organization.id}`);
|
|
630
|
+
assertEquals(read.status, 404);
|
|
631
|
+
const again = await call(seats.admin.token, "DELETE", `/api/organizations/${organization.id}/members/${leaver.id}/member`);
|
|
632
|
+
assertEquals(again.status, 404);
|
|
633
|
+
});
|
|
634
|
+
it("never leaves an organization without an owner", async () => {
|
|
635
|
+
const reply = await call(seats.owner.token, "DELETE", `/api/organizations/${organization.id}/members/${seats.owner.id}/owner`);
|
|
636
|
+
assertEquals(reply.status, 409);
|
|
637
|
+
});
|
|
638
|
+
it("deletes an organization for its owner alone, and not while it holds a grant", async () => {
|
|
639
|
+
const doomed = await createOrganization(seats.owner.token);
|
|
640
|
+
await seat(seats.owner.token, doomed.id, seats.admin, "admin");
|
|
641
|
+
await seat(seats.owner.token, doomed.id, seats.member, "member");
|
|
642
|
+
const byAdmin = await call(seats.admin.token, "DELETE", `/api/organizations/${doomed.id}`);
|
|
643
|
+
assertEquals(byAdmin.status, 403);
|
|
644
|
+
const byMember = await call(seats.member.token, "DELETE", `/api/organizations/${doomed.id}`);
|
|
645
|
+
assertEquals(byMember.status, 404);
|
|
646
|
+
const holding = await createOrganization(seats.owner.token);
|
|
647
|
+
await tenant.grant({
|
|
648
|
+
resource: { type: "contract_document", id: `held-${holding.id}` },
|
|
649
|
+
subject: { type: "organization", id: holding.id },
|
|
650
|
+
permissions: ["contract:read"],
|
|
651
|
+
});
|
|
652
|
+
const blocked = await call(seats.owner.token, "DELETE", `/api/organizations/${holding.id}`);
|
|
653
|
+
assertEquals(blocked.status, 409);
|
|
654
|
+
const deleted = await call(seats.owner.token, "DELETE", `/api/organizations/${doomed.id}`);
|
|
655
|
+
assertEquals(deleted.status, 204);
|
|
656
|
+
const gone = await call(seats.member.token, "GET", `/api/organizations/${doomed.id}`);
|
|
657
|
+
assertEquals(gone.status, 404);
|
|
658
|
+
assertFalse((await membershipsOf(seats.member.token)).some((entry) => entry.org_id === doomed.id));
|
|
659
|
+
});
|
|
660
|
+
it("refuses a caller with no bearer token", async () => {
|
|
661
|
+
const reply = await call(null, "GET", "/api/organizations");
|
|
662
|
+
assertEquals(reply.status, 401);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
describe("the account API", () => {
|
|
666
|
+
it("answers a new person's own metadata bucket, empty, and no cache keeps it", async () => {
|
|
667
|
+
const caller = await person();
|
|
668
|
+
const reply = await call(caller.token, "GET", "/api/account");
|
|
669
|
+
assertEquals(reply.status, 200);
|
|
670
|
+
assertEquals(reply.body, { userMetadata: {} });
|
|
671
|
+
assertStringIncludes(reply.headers.get("cache-control") ?? "", "no-store");
|
|
672
|
+
});
|
|
673
|
+
it("merges a patch shallowly, deleting keys set to null and keeping keys it does not name", async () => {
|
|
674
|
+
const caller = await person();
|
|
675
|
+
const first = await call(caller.token, "PATCH", "/api/account", {
|
|
676
|
+
userMetadata: { theme: "dark", locale: "en", nested: { a: 1 } },
|
|
677
|
+
});
|
|
678
|
+
assertEquals(first.status, 200);
|
|
679
|
+
assertEquals(first.body, {
|
|
680
|
+
userMetadata: { theme: "dark", locale: "en", nested: { a: 1 } },
|
|
681
|
+
});
|
|
682
|
+
const second = await call(caller.token, "PATCH", "/api/account", {
|
|
683
|
+
userMetadata: { locale: null, nested: { b: 2 } },
|
|
684
|
+
});
|
|
685
|
+
assertEquals(second.body, {
|
|
686
|
+
userMetadata: { theme: "dark", nested: { b: 2 } },
|
|
687
|
+
});
|
|
688
|
+
const read = await call(caller.token, "GET", "/api/account");
|
|
689
|
+
assertEquals(read.body, second.body);
|
|
690
|
+
});
|
|
691
|
+
it("refuses anything but a userMetadata object", async () => {
|
|
692
|
+
const caller = await person();
|
|
693
|
+
for (const body of [
|
|
694
|
+
{},
|
|
695
|
+
{ userMetadata: ["dark"] },
|
|
696
|
+
{ userMetadata: {}, metadata: { role: "admin" } },
|
|
697
|
+
]) {
|
|
698
|
+
const reply = await call(caller.token, "PATCH", "/api/account", body);
|
|
699
|
+
assertEquals(reply.status, 400, JSON.stringify(body));
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
it("keeps the bucket to eight levels of nesting", async () => {
|
|
703
|
+
const caller = await person();
|
|
704
|
+
const nested = (levels) => levels <= 1 ? { leaf: true } : { next: nested(levels - 1) };
|
|
705
|
+
const deepest = await call(caller.token, "PATCH", "/api/account", {
|
|
706
|
+
userMetadata: nested(8),
|
|
707
|
+
});
|
|
708
|
+
assertEquals(deepest.status, 200);
|
|
709
|
+
const tooDeep = await call(caller.token, "PATCH", "/api/account", {
|
|
710
|
+
userMetadata: nested(9),
|
|
711
|
+
});
|
|
712
|
+
assertEquals(tooDeep.status, 400);
|
|
713
|
+
});
|
|
714
|
+
it("measures the metadata limit in UTF-8 bytes and preserves the bucket on refusal", async () => {
|
|
715
|
+
const caller = await person();
|
|
716
|
+
const notes = "界".repeat(6 * 1024);
|
|
717
|
+
const reply = await call(caller.token, "PATCH", "/api/account", {
|
|
718
|
+
userMetadata: { notes },
|
|
719
|
+
});
|
|
720
|
+
assertEquals(reply.status, 400);
|
|
721
|
+
const read = await call(caller.token, "GET", "/api/account");
|
|
722
|
+
assertEquals(read.body, { userMetadata: {} });
|
|
723
|
+
});
|
|
724
|
+
it("keeps the merged bucket to sixteen kilobytes, however small each patch is", async () => {
|
|
725
|
+
const caller = await person();
|
|
726
|
+
const half = "x".repeat(9 * 1024);
|
|
727
|
+
const first = await call(caller.token, "PATCH", "/api/account", {
|
|
728
|
+
userMetadata: { first: half },
|
|
729
|
+
});
|
|
730
|
+
assertEquals(first.status, 200);
|
|
731
|
+
const second = await call(caller.token, "PATCH", "/api/account", {
|
|
732
|
+
userMetadata: { second: half },
|
|
733
|
+
});
|
|
734
|
+
assertEquals(second.status, 400);
|
|
735
|
+
const read = await call(caller.token, "GET", "/api/account");
|
|
736
|
+
assertEquals(read.body, { userMetadata: { first: half } });
|
|
737
|
+
});
|
|
738
|
+
it("refuses a caller with no bearer token", async () => {
|
|
739
|
+
const reply = await call(null, "GET", "/api/account");
|
|
740
|
+
assertEquals(reply.status, 401);
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
describe("the account sessions API", () => {
|
|
744
|
+
async function sessionsOf(accessToken) {
|
|
745
|
+
const reply = await call(accessToken, "GET", "/api/account/sessions");
|
|
746
|
+
assertEquals(reply.status, 200);
|
|
747
|
+
return reply.body.sessions;
|
|
748
|
+
}
|
|
749
|
+
const currentOf = (sessions) => sessions.filter((session) => session.current).map(({ id }) => id);
|
|
750
|
+
async function refresh(refreshToken) {
|
|
751
|
+
return await send(metadata.token_endpoint, {
|
|
752
|
+
method: "POST",
|
|
753
|
+
headers: { authorization: clientAuth() },
|
|
754
|
+
body: new URLSearchParams({
|
|
755
|
+
grant_type: "refresh_token",
|
|
756
|
+
refresh_token: refreshToken,
|
|
757
|
+
}),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
it("lists every sign-in as a session, the credential's own first", async () => {
|
|
761
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
762
|
+
const first = await tenant.signIn(id);
|
|
763
|
+
const second = await tenant.signIn(id);
|
|
764
|
+
const listed = await sessionsOf(second.access_token);
|
|
765
|
+
assertEquals(listed.length, 2);
|
|
766
|
+
assertEquals(currentOf(listed), [listed[0].id]);
|
|
767
|
+
assertEquals(Object.keys(listed[0]).sort(), [
|
|
768
|
+
"createdAt",
|
|
769
|
+
"current",
|
|
770
|
+
"id",
|
|
771
|
+
"ipAddress",
|
|
772
|
+
"lastActiveAt",
|
|
773
|
+
"userAgent",
|
|
774
|
+
]);
|
|
775
|
+
const fromFirst = await sessionsOf(first.access_token);
|
|
776
|
+
assertEquals(fromFirst.map((session) => session.id).sort(), listed.map((session) => session.id).sort());
|
|
777
|
+
assertEquals(currentOf(fromFirst), [listed[1].id]);
|
|
778
|
+
});
|
|
779
|
+
it("keeps a refreshed credential on the session it was issued from", async () => {
|
|
780
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
781
|
+
const first = await tenant.signIn(id);
|
|
782
|
+
await tenant.signIn(id);
|
|
783
|
+
const issuedFrom = currentOf(await sessionsOf(first.access_token));
|
|
784
|
+
const response = await refresh(first.refresh_token);
|
|
785
|
+
assertEquals(response.status, 200);
|
|
786
|
+
const refreshed = await response.json();
|
|
787
|
+
assertEquals(currentOf(await sessionsOf(refreshed.access_token)), issuedFrom);
|
|
788
|
+
});
|
|
789
|
+
it("refuses to end the caller's own session", async () => {
|
|
790
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
791
|
+
const { access_token } = await tenant.signIn(id);
|
|
792
|
+
const [own] = currentOf(await sessionsOf(access_token));
|
|
793
|
+
const reply = await call(access_token, "DELETE", `/api/account/sessions/${own}`);
|
|
794
|
+
assertEquals(reply.status, 409);
|
|
795
|
+
assertEquals(reply.body.reason, "current_session");
|
|
796
|
+
});
|
|
797
|
+
it("ends another of the caller's sessions and every token issued from it", async () => {
|
|
798
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
799
|
+
const other = await tenant.signIn(id);
|
|
800
|
+
const current = await tenant.signIn(id);
|
|
801
|
+
const [otherSession] = currentOf(await sessionsOf(other.access_token));
|
|
802
|
+
const ended = await call(current.access_token, "DELETE", `/api/account/sessions/${otherSession}`);
|
|
803
|
+
assertEquals(ended.status, 204);
|
|
804
|
+
assertEquals((await introspect(other.access_token)).active, false);
|
|
805
|
+
const refused = await refresh(other.refresh_token);
|
|
806
|
+
await refused.body?.cancel();
|
|
807
|
+
assertEquals(refused.status, 400);
|
|
808
|
+
const remaining = await sessionsOf(current.access_token);
|
|
809
|
+
assertEquals(remaining.map((session) => session.current), [true]);
|
|
810
|
+
});
|
|
811
|
+
it("answers another person's session, an unknown id and a malformed id with 404", async () => {
|
|
812
|
+
const caller = await person();
|
|
813
|
+
const stranger = await person();
|
|
814
|
+
const [theirs] = currentOf(await sessionsOf(stranger.token));
|
|
815
|
+
for (const sessionId of [theirs, crypto.randomUUID(), "not-a-session"]) {
|
|
816
|
+
const reply = await call(caller.token, "DELETE", `/api/account/sessions/${sessionId}`);
|
|
817
|
+
assertEquals(reply.status, 404, sessionId);
|
|
818
|
+
}
|
|
819
|
+
assertEquals((await sessionsOf(stranger.token)).length, 1);
|
|
820
|
+
});
|
|
821
|
+
it("ends every other session and keeps the caller's", async () => {
|
|
822
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
823
|
+
const first = await tenant.signIn(id);
|
|
824
|
+
await tenant.signIn(id);
|
|
825
|
+
const kept = await tenant.signIn(id);
|
|
826
|
+
const reply = await call(kept.access_token, "POST", "/api/account/sessions/revoke-others");
|
|
827
|
+
assertEquals(reply.status, 200);
|
|
828
|
+
assertEquals(reply.body, { revoked: 2 });
|
|
829
|
+
const remaining = await sessionsOf(kept.access_token);
|
|
830
|
+
assertEquals(remaining.map((session) => session.current), [true]);
|
|
831
|
+
assertEquals((await introspect(first.access_token)).active, false);
|
|
832
|
+
});
|
|
833
|
+
it("ends a login session when a token issued from it is revoked", async () => {
|
|
834
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
835
|
+
const revoked = await tenant.signIn(id);
|
|
836
|
+
const kept = await tenant.signIn(id);
|
|
837
|
+
assert(metadata.revocation_endpoint, "the tenant advertises no revocation endpoint");
|
|
838
|
+
const response = await send(metadata.revocation_endpoint, {
|
|
839
|
+
method: "POST",
|
|
840
|
+
headers: { authorization: clientAuth() },
|
|
841
|
+
body: new URLSearchParams({
|
|
842
|
+
token: revoked.refresh_token,
|
|
843
|
+
token_type_hint: "refresh_token",
|
|
844
|
+
}),
|
|
845
|
+
});
|
|
846
|
+
await response.body?.cancel();
|
|
847
|
+
assertEquals(response.status, 200);
|
|
848
|
+
assertEquals((await introspect(revoked.access_token)).active, false);
|
|
849
|
+
const remaining = await sessionsOf(kept.access_token);
|
|
850
|
+
assertEquals(remaining.map((session) => session.current), [true]);
|
|
851
|
+
});
|
|
852
|
+
it("revokes a login session's earlier credential when the same browser signs in again", async () => {
|
|
853
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
854
|
+
const first = await tenant.signIn(id);
|
|
855
|
+
const again = await tenant.signIn(id, undefined, { sameBrowser: true });
|
|
856
|
+
assertEquals((await introspect(first.access_token)).active, false);
|
|
857
|
+
const refused = await refresh(first.refresh_token);
|
|
858
|
+
await refused.body?.cancel();
|
|
859
|
+
assertEquals(refused.status, 400);
|
|
860
|
+
assertEquals((await introspect(again.access_token)).active, true);
|
|
861
|
+
const listed = await sessionsOf(again.access_token);
|
|
862
|
+
assertEquals(listed.map((session) => session.current), [true]);
|
|
863
|
+
});
|
|
864
|
+
it("refuses a body that names anything", async () => {
|
|
865
|
+
const caller = await person();
|
|
866
|
+
const reply = await call(caller.token, "POST", "/api/account/sessions/revoke-others", { userId: caller.id });
|
|
867
|
+
assertEquals(reply.status, 400);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
describe("the linked accounts API", () => {
|
|
871
|
+
async function linkedOf(accessToken) {
|
|
872
|
+
const reply = await call(accessToken, "GET", "/api/account/linked-accounts");
|
|
873
|
+
assertEquals(reply.status, 200);
|
|
874
|
+
return reply.body;
|
|
875
|
+
}
|
|
876
|
+
it("lists a person's linked accounts and whether they have a password", async () => {
|
|
877
|
+
const caller = await person();
|
|
878
|
+
const email = newEmail();
|
|
879
|
+
const linked = await tenant.linkAccount(caller.id, {
|
|
880
|
+
provider: "github",
|
|
881
|
+
email,
|
|
882
|
+
});
|
|
883
|
+
const body = await linkedOf(caller.token);
|
|
884
|
+
assertEquals(body.hasPassword, true);
|
|
885
|
+
assert(Array.isArray(body.connectable));
|
|
886
|
+
assertEquals(body.identities.length, 1);
|
|
887
|
+
const [identity] = body.identities;
|
|
888
|
+
assertEquals(identity.id, linked);
|
|
889
|
+
assertEquals(identity.provider, "github");
|
|
890
|
+
assertEquals(identity.email, email);
|
|
891
|
+
assert(identity.displayName, "a linked account names its provider");
|
|
892
|
+
assert(identity.createdAt, "a linked account says when it was linked");
|
|
893
|
+
});
|
|
894
|
+
it("disconnects a linked account while a password remains", async () => {
|
|
895
|
+
const caller = await person();
|
|
896
|
+
const linked = await tenant.linkAccount(caller.id, {
|
|
897
|
+
provider: "github",
|
|
898
|
+
email: null,
|
|
899
|
+
});
|
|
900
|
+
const reply = await call(caller.token, "DELETE", `/api/account/linked-accounts/${linked}`);
|
|
901
|
+
assertEquals(reply.status, 204);
|
|
902
|
+
assertEquals((await linkedOf(caller.token)).identities, []);
|
|
903
|
+
});
|
|
904
|
+
it("refuses to disconnect a person's last way to sign in", async () => {
|
|
905
|
+
const id = await tenant.addUser([], {
|
|
906
|
+
email: newEmail(),
|
|
907
|
+
password: false,
|
|
908
|
+
});
|
|
909
|
+
const linked = await tenant.linkAccount(id, {
|
|
910
|
+
provider: "github",
|
|
911
|
+
email: null,
|
|
912
|
+
});
|
|
913
|
+
const { access_token } = await tenant.signIn(id);
|
|
914
|
+
assertEquals((await linkedOf(access_token)).hasPassword, false);
|
|
915
|
+
const reply = await call(access_token, "DELETE", `/api/account/linked-accounts/${linked}`);
|
|
916
|
+
assertEquals(reply.status, 409);
|
|
917
|
+
assertEquals(reply.body.reason, "last_method");
|
|
918
|
+
assertEquals((await linkedOf(access_token)).identities.map((entry) => entry.id), [linked]);
|
|
919
|
+
});
|
|
920
|
+
it("answers another person's linked account, an unknown id and a malformed id with 404", async () => {
|
|
921
|
+
const caller = await person();
|
|
922
|
+
const stranger = await person();
|
|
923
|
+
const theirs = await tenant.linkAccount(stranger.id, {
|
|
924
|
+
provider: "github",
|
|
925
|
+
email: null,
|
|
926
|
+
});
|
|
927
|
+
for (const identityId of [theirs, crypto.randomUUID(), "not-an-id"]) {
|
|
928
|
+
const reply = await call(caller.token, "DELETE", `/api/account/linked-accounts/${identityId}`);
|
|
929
|
+
assertEquals(reply.status, 404, identityId);
|
|
930
|
+
}
|
|
931
|
+
assertEquals((await linkedOf(stranger.token)).identities.map((entry) => entry.id), [theirs]);
|
|
932
|
+
});
|
|
933
|
+
});
|
|
308
934
|
});
|
|
309
935
|
}
|