@cosmicdrift/kumiko-bundled-features 0.215.7 → 0.217.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/package.json +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +35 -2
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +86 -37
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/handlers/invite-create.write.ts +12 -3
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +14 -2
- package/src/auth-email-password/web/auth-client.ts +5 -15
- package/src/shared/map-with-concurrency.test.ts +1 -1
- package/src/tenant/__tests__/members-screens.boot.test.ts +39 -2
- package/src/tenant/__tests__/seed-testing.integration.test.ts +11 -9
- package/src/tenant/__tests__/tenant-security.integration.test.ts +433 -5
- package/src/tenant/__tests__/tenant.integration.test.ts +3 -2
- package/src/tenant/constants.ts +3 -0
- package/src/tenant/feature.ts +4 -3
- package/src/tenant/handlers/team-list.query.ts +3 -0
- package/src/tenant/handlers/update-member-roles.write.ts +107 -20
- package/src/tenant/i18n.ts +10 -1
- package/src/tenant/membership-roles.ts +5 -2
- package/src/tenant/screens.ts +24 -0
- package/src/tenant/seeding.ts +9 -6
- package/src/user/__tests__/admin-screens.boot.test.ts +22 -0
- package/src/user/__tests__/user.integration.test.ts +561 -3
- package/src/user/changes.json +7 -0
- package/src/user/constants.ts +8 -0
- package/src/user/handlers/create.write.ts +36 -18
- package/src/user/handlers/list.query.ts +116 -4
- package/src/user/handlers/update-roles.ts +172 -0
- package/src/user/handlers/update.write.ts +32 -8
- package/src/user/i18n.ts +3 -0
- package/src/user/schema/user.ts +18 -10
- package/src/user/screens.ts +6 -2
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +43 -3
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
6
6
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
7
|
import { access, type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
9
|
import {
|
|
9
10
|
setupTestStack,
|
|
10
11
|
type TestStack,
|
|
12
|
+
TestUsers,
|
|
11
13
|
unsafeCreateEntityTable,
|
|
12
14
|
unsafePushTables,
|
|
13
15
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
@@ -15,6 +17,7 @@ import { expectErrorIncludes, rolesOf, seedRow } from "@cosmicdrift/kumiko-frame
|
|
|
15
17
|
import { Temporal } from "temporal-polyfill";
|
|
16
18
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
17
19
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
20
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
18
21
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
19
22
|
import { createConfigFeature } from "../../config";
|
|
20
23
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -23,13 +26,18 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
|
|
|
23
26
|
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
24
27
|
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
25
28
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
26
|
-
import { userSessionEntity, userSessionTable } from "../../sessions
|
|
29
|
+
import { createSessionsFeature, userSessionEntity, userSessionTable } from "../../sessions";
|
|
27
30
|
import { hashPassword } from "../../shared";
|
|
28
31
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
29
32
|
import { createUserFeature } from "../../user/feature";
|
|
30
33
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
31
34
|
import { seedUser } from "../../user/seeding";
|
|
32
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
37
|
+
MEMBERS_SCREEN_ID,
|
|
38
|
+
TenantHandlers,
|
|
39
|
+
TenantQueries,
|
|
40
|
+
} from "../constants";
|
|
33
41
|
import { createTenantFeature } from "../feature";
|
|
34
42
|
import { tenantInvitationEntity, tenantInvitationsTable } from "../invitation-table";
|
|
35
43
|
import { tenantMembershipsTable } from "../membership-table";
|
|
@@ -66,6 +74,10 @@ beforeAll(async () => {
|
|
|
66
74
|
// inviteScreen: this suite dispatches AuthHandlers.inviteCreate and
|
|
67
75
|
// needs /members' invite-create actionForm screen registered too.
|
|
68
76
|
createTenantFeature({ inviteScreen: true }),
|
|
77
|
+
// Sessions required so updateMemberRoles can revoke target JWTs via
|
|
78
|
+
// sessions:write:user-session:revoke-all-for-user (best-effort lookup).
|
|
79
|
+
authFoundationFeature,
|
|
80
|
+
createSessionsFeature(),
|
|
69
81
|
createTemplateResolverFeature(),
|
|
70
82
|
createRendererFoundationFeature(),
|
|
71
83
|
createDeliveryFeature(),
|
|
@@ -103,6 +115,7 @@ beforeAll(async () => {
|
|
|
103
115
|
tenantMembershipsTable,
|
|
104
116
|
notificationPreferencesTable,
|
|
105
117
|
});
|
|
118
|
+
await createEventsTable(stack.db);
|
|
106
119
|
});
|
|
107
120
|
|
|
108
121
|
afterAll(async () => {
|
|
@@ -115,6 +128,7 @@ beforeEach(async () => {
|
|
|
115
128
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
|
|
116
129
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
|
|
117
130
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
131
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${eventsTable.tableName}"`);
|
|
118
132
|
emailTransport.sent.length = 0;
|
|
119
133
|
const keys = await stack.redis.redis.keys("invite:*");
|
|
120
134
|
if (keys.length > 0) await stack.redis.redis.del(...keys);
|
|
@@ -249,6 +263,15 @@ describe("regular User is denied members-admin surface", () => {
|
|
|
249
263
|
regularUserB(),
|
|
250
264
|
),
|
|
251
265
|
],
|
|
266
|
+
[
|
|
267
|
+
"update-member-roles",
|
|
268
|
+
() =>
|
|
269
|
+
stack.http.write(
|
|
270
|
+
TenantHandlers.updateMemberRoles,
|
|
271
|
+
{ userId: regularUserBId, tenantId: TENANT_B_ID, roles: ["Admin"] },
|
|
272
|
+
regularUserB(),
|
|
273
|
+
),
|
|
274
|
+
],
|
|
252
275
|
] as const) {
|
|
253
276
|
const res = await fn();
|
|
254
277
|
expect(res.status, label).toBe(403);
|
|
@@ -277,6 +300,35 @@ describe("privilege escalation via invite role", () => {
|
|
|
277
300
|
expect(rows).toHaveLength(0);
|
|
278
301
|
expect(emailTransport.sent).toHaveLength(0);
|
|
279
302
|
});
|
|
303
|
+
|
|
304
|
+
test("Admin cannot invite as TenantAdmin (elevation guard)", async () => {
|
|
305
|
+
const { id: adminUserId } = await seedUser(stack.db, {
|
|
306
|
+
email: "admin-inviter@example.com",
|
|
307
|
+
displayName: "Admin Inviter",
|
|
308
|
+
});
|
|
309
|
+
await seedTenantMembership(stack.db, {
|
|
310
|
+
userId: adminUserId,
|
|
311
|
+
tenantId: TENANT_A_ID,
|
|
312
|
+
roles: ["Admin"],
|
|
313
|
+
});
|
|
314
|
+
const adminUser: SessionUser = { id: adminUserId, tenantId: TENANT_A_ID, roles: ["Admin"] };
|
|
315
|
+
|
|
316
|
+
const err = await stack.http.writeErr(
|
|
317
|
+
AuthHandlers.inviteCreate,
|
|
318
|
+
{ email: "elevated-invitee@example.com", role: "TenantAdmin" },
|
|
319
|
+
adminUser,
|
|
320
|
+
);
|
|
321
|
+
expectErrorIncludes(err, "unassignable_membership_role");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("TenantAdmin cannot invite with unknown unranked role (elevation guard)", async () => {
|
|
325
|
+
const err = await stack.http.writeErr(
|
|
326
|
+
AuthHandlers.inviteCreate,
|
|
327
|
+
{ email: "unknown-role@example.com", role: "SuperCustomRole" },
|
|
328
|
+
tenantAdminA(),
|
|
329
|
+
);
|
|
330
|
+
expectErrorIncludes(err, "unassignable_membership_role");
|
|
331
|
+
});
|
|
280
332
|
});
|
|
281
333
|
|
|
282
334
|
describe("tenant isolation on cancel-invitation", () => {
|
|
@@ -555,13 +607,389 @@ describe("cancel-invitation on the /members surface genuinely cancels", () => {
|
|
|
555
607
|
});
|
|
556
608
|
});
|
|
557
609
|
|
|
558
|
-
describe("updateMemberRoles
|
|
559
|
-
test("TenantAdmin
|
|
610
|
+
describe("updateMemberRoles — TenantAdmin session-scoped path and safety gates", () => {
|
|
611
|
+
test("TenantAdmin can update roles of a member in own tenant", async () => {
|
|
612
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
613
|
+
email: "updatable-member@example.com",
|
|
614
|
+
displayName: "Updatable Member",
|
|
615
|
+
passwordHash: await hashPassword("pw-update-1234"),
|
|
616
|
+
emailVerified: true,
|
|
617
|
+
});
|
|
618
|
+
await seedTenantMembership(stack.db, {
|
|
619
|
+
userId: memberUserId,
|
|
620
|
+
tenantId: TENANT_A_ID,
|
|
621
|
+
roles: ["User"],
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const res = await stack.http.writeOk(
|
|
625
|
+
TenantHandlers.updateMemberRoles,
|
|
626
|
+
{ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] },
|
|
627
|
+
tenantAdminA(),
|
|
628
|
+
);
|
|
629
|
+
expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
|
|
630
|
+
|
|
631
|
+
const rows = await selectMany(stack.db, tenantMembershipsTable, {
|
|
632
|
+
userId: memberUserId,
|
|
633
|
+
tenantId: TENANT_A_ID,
|
|
634
|
+
});
|
|
635
|
+
expect(rows).toHaveLength(1);
|
|
636
|
+
expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("TenantAdmin cannot update membership of a user in another tenant", async () => {
|
|
640
|
+
const { id: otherTenantMemberId } = await seedUser(stack.db, {
|
|
641
|
+
email: "other-tenant-member@example.com",
|
|
642
|
+
displayName: "Other Tenant Member",
|
|
643
|
+
passwordHash: await hashPassword("pw-other-1234"),
|
|
644
|
+
emailVerified: true,
|
|
645
|
+
});
|
|
646
|
+
await seedTenantMembership(stack.db, {
|
|
647
|
+
userId: otherTenantMemberId,
|
|
648
|
+
tenantId: TENANT_B_ID,
|
|
649
|
+
roles: ["User"],
|
|
650
|
+
});
|
|
651
|
+
|
|
560
652
|
const err = await stack.http.writeErr(
|
|
561
653
|
TenantHandlers.updateMemberRoles,
|
|
562
|
-
{ userId:
|
|
654
|
+
{ userId: otherTenantMemberId, tenantId: TENANT_B_ID, roles: ["Admin"] },
|
|
655
|
+
tenantAdminA(),
|
|
656
|
+
);
|
|
657
|
+
expectErrorIncludes(err, "membership_not_found");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("TenantAdmin cannot assign reserved/global roles", async () => {
|
|
661
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
662
|
+
email: "reserved-role-target@example.com",
|
|
663
|
+
displayName: "Reserved Role Target",
|
|
664
|
+
passwordHash: await hashPassword("pw-res-1234"),
|
|
665
|
+
emailVerified: true,
|
|
666
|
+
});
|
|
667
|
+
await seedTenantMembership(stack.db, {
|
|
668
|
+
userId: memberUserId,
|
|
669
|
+
tenantId: TENANT_A_ID,
|
|
670
|
+
roles: ["User"],
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
for (const role of FORBIDDEN_ROLES) {
|
|
674
|
+
const err = await stack.http.writeErr(
|
|
675
|
+
TenantHandlers.updateMemberRoles,
|
|
676
|
+
{ userId: memberUserId, tenantId: TENANT_A_ID, roles: [role] },
|
|
677
|
+
tenantAdminA(),
|
|
678
|
+
);
|
|
679
|
+
expectErrorIncludes(err, "access_denied");
|
|
680
|
+
expectErrorIncludes(err, "reserved_membership_role");
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("TenantAdmin can demote a member that currently holds an app-defined role", async () => {
|
|
685
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
686
|
+
email: "editor-demote-target@example.com",
|
|
687
|
+
displayName: "Editor Demote Target",
|
|
688
|
+
passwordHash: await hashPassword("pw-ed-1234"),
|
|
689
|
+
emailVerified: true,
|
|
690
|
+
});
|
|
691
|
+
await seedTenantMembership(stack.db, {
|
|
692
|
+
userId: memberUserId,
|
|
693
|
+
tenantId: TENANT_A_ID,
|
|
694
|
+
roles: ["Editor"],
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const res = await stack.http.writeOk(
|
|
698
|
+
TenantHandlers.updateMemberRoles,
|
|
699
|
+
{ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["User"] },
|
|
700
|
+
tenantAdminA(),
|
|
701
|
+
);
|
|
702
|
+
expect(res).toMatchObject({ userId: memberUserId, roles: ["User"] });
|
|
703
|
+
|
|
704
|
+
const rows = await selectMany(stack.db, tenantMembershipsTable, {
|
|
705
|
+
userId: memberUserId,
|
|
706
|
+
tenantId: TENANT_A_ID,
|
|
707
|
+
});
|
|
708
|
+
expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["User"]);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("TenantAdmin cannot assign unknown role (elevation guard)", async () => {
|
|
712
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
713
|
+
email: "elevation-guard-target@example.com",
|
|
714
|
+
displayName: "Elevation Guard Target",
|
|
715
|
+
passwordHash: await hashPassword("pw-elev-1234"),
|
|
716
|
+
emailVerified: true,
|
|
717
|
+
});
|
|
718
|
+
await seedTenantMembership(stack.db, {
|
|
719
|
+
userId: memberUserId,
|
|
720
|
+
tenantId: TENANT_A_ID,
|
|
721
|
+
roles: ["User"],
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const err = await stack.http.writeErr(
|
|
725
|
+
TenantHandlers.updateMemberRoles,
|
|
726
|
+
{ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["SuperCustomRole"] },
|
|
563
727
|
tenantAdminA(),
|
|
564
728
|
);
|
|
565
729
|
expectErrorIncludes(err, "access_denied");
|
|
730
|
+
expectErrorIncludes(err, "unassignable_membership_role");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("Admin cannot elevate target to TenantAdmin or modify a TenantAdmin", async () => {
|
|
734
|
+
const { id: adminUserId } = await seedUser(stack.db, {
|
|
735
|
+
email: "plain-admin@example.com",
|
|
736
|
+
displayName: "Plain Admin",
|
|
737
|
+
passwordHash: await hashPassword("pw-adm-1234"),
|
|
738
|
+
emailVerified: true,
|
|
739
|
+
});
|
|
740
|
+
await seedTenantMembership(stack.db, {
|
|
741
|
+
userId: adminUserId,
|
|
742
|
+
tenantId: TENANT_A_ID,
|
|
743
|
+
roles: ["Admin"],
|
|
744
|
+
});
|
|
745
|
+
const adminUser: SessionUser = { id: adminUserId, tenantId: TENANT_A_ID, roles: ["Admin"] };
|
|
746
|
+
|
|
747
|
+
const { id: regularMemberId } = await seedUser(stack.db, {
|
|
748
|
+
email: "plain-member@example.com",
|
|
749
|
+
displayName: "Plain Member",
|
|
750
|
+
passwordHash: await hashPassword("pw-mem-1234"),
|
|
751
|
+
emailVerified: true,
|
|
752
|
+
});
|
|
753
|
+
await seedTenantMembership(stack.db, {
|
|
754
|
+
userId: regularMemberId,
|
|
755
|
+
tenantId: TENANT_A_ID,
|
|
756
|
+
roles: ["User"],
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// 1. Admin tries to assign TenantAdmin to regular member -> rejected
|
|
760
|
+
const errElevate = await stack.http.writeErr(
|
|
761
|
+
TenantHandlers.updateMemberRoles,
|
|
762
|
+
{ userId: regularMemberId, tenantId: TENANT_A_ID, roles: ["TenantAdmin"] },
|
|
763
|
+
adminUser,
|
|
764
|
+
);
|
|
765
|
+
expectErrorIncludes(errElevate, "unassignable_membership_role");
|
|
766
|
+
|
|
767
|
+
// 2. Admin tries to modify existing TenantAdmin -> rejected
|
|
768
|
+
const errModifyHigher = await stack.http.writeErr(
|
|
769
|
+
TenantHandlers.updateMemberRoles,
|
|
770
|
+
{ userId: tenantAdminAId, tenantId: TENANT_A_ID, roles: ["Admin"] },
|
|
771
|
+
adminUser,
|
|
772
|
+
);
|
|
773
|
+
expectErrorIncludes(errModifyHigher, "unassignable_membership_role");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("refuses demoting/removing the last TenantAdmin", async () => {
|
|
777
|
+
// tenantAdminA is currently the only TenantAdmin in TENANT_A_ID
|
|
778
|
+
const err = await stack.http.writeErr(
|
|
779
|
+
TenantHandlers.updateMemberRoles,
|
|
780
|
+
{ userId: tenantAdminAId, tenantId: TENANT_A_ID, roles: ["User"] },
|
|
781
|
+
tenantAdminA(),
|
|
782
|
+
);
|
|
783
|
+
expectErrorIncludes(err, "last_tenant_admin");
|
|
784
|
+
expectErrorIncludes(err, "cannot demote the last tenant admin");
|
|
785
|
+
|
|
786
|
+
// Verify role remains unchanged
|
|
787
|
+
const rows = await selectMany(stack.db, tenantMembershipsTable, {
|
|
788
|
+
userId: tenantAdminAId,
|
|
789
|
+
tenantId: TENANT_A_ID,
|
|
790
|
+
});
|
|
791
|
+
expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["TenantAdmin"]);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("allows demoting a TenantAdmin when another TenantAdmin exists", async () => {
|
|
795
|
+
const { id: secondAdminId } = await seedUser(stack.db, {
|
|
796
|
+
email: "second-admin@example.com",
|
|
797
|
+
displayName: "Second Admin",
|
|
798
|
+
passwordHash: await hashPassword("pw-adm2-1234"),
|
|
799
|
+
emailVerified: true,
|
|
800
|
+
});
|
|
801
|
+
await seedTenantMembership(stack.db, {
|
|
802
|
+
userId: secondAdminId,
|
|
803
|
+
tenantId: TENANT_A_ID,
|
|
804
|
+
roles: ["TenantAdmin"],
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Now there are 2 TenantAdmins (tenantAdminA and secondAdminId)
|
|
808
|
+
const res = await stack.http.writeOk(
|
|
809
|
+
TenantHandlers.updateMemberRoles,
|
|
810
|
+
{ userId: secondAdminId, tenantId: TENANT_A_ID, roles: ["Admin"] },
|
|
811
|
+
tenantAdminA(),
|
|
812
|
+
);
|
|
813
|
+
expect(res).toMatchObject({ userId: secondAdminId, roles: ["Admin"] });
|
|
814
|
+
|
|
815
|
+
const rows = await selectMany(stack.db, tenantMembershipsTable, {
|
|
816
|
+
userId: secondAdminId,
|
|
817
|
+
tenantId: TENANT_A_ID,
|
|
818
|
+
});
|
|
819
|
+
expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("SystemAdmin can update member roles across tenants", async () => {
|
|
823
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
824
|
+
email: "sysadmin-cross-target@example.com",
|
|
825
|
+
displayName: "SysAdmin Target",
|
|
826
|
+
passwordHash: await hashPassword("pw-sys-1234"),
|
|
827
|
+
emailVerified: true,
|
|
828
|
+
});
|
|
829
|
+
await seedTenantMembership(stack.db, {
|
|
830
|
+
userId: memberUserId,
|
|
831
|
+
tenantId: TENANT_B_ID,
|
|
832
|
+
roles: ["User"],
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const res = await stack.http.writeOk(
|
|
836
|
+
TenantHandlers.updateMemberRoles,
|
|
837
|
+
{ userId: memberUserId, tenantId: TENANT_B_ID, roles: ["Admin"] },
|
|
838
|
+
TestUsers.systemAdmin,
|
|
839
|
+
);
|
|
840
|
+
expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_B_ID, roles: ["Admin"] });
|
|
841
|
+
|
|
842
|
+
const rows = await selectMany(stack.db, tenantMembershipsTable, {
|
|
843
|
+
userId: memberUserId,
|
|
844
|
+
tenantId: TENANT_B_ID,
|
|
845
|
+
});
|
|
846
|
+
expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("updateMemberRoles appends audit event and invalidates active sessions for target user", async () => {
|
|
850
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
851
|
+
email: "session-target@example.com",
|
|
852
|
+
displayName: "Session Target",
|
|
853
|
+
});
|
|
854
|
+
await seedTenantMembership(stack.db, {
|
|
855
|
+
userId: memberUserId,
|
|
856
|
+
tenantId: TENANT_A_ID,
|
|
857
|
+
roles: ["User"],
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const sessionTarget1Id = crypto.randomUUID();
|
|
861
|
+
const sessionTarget2Id = crypto.randomUUID();
|
|
862
|
+
const sessionAdminId = crypto.randomUUID();
|
|
863
|
+
await seedRow(stack.db, userSessionTable, {
|
|
864
|
+
id: sessionTarget1Id,
|
|
865
|
+
userId: memberUserId,
|
|
866
|
+
tenantId: TENANT_A_ID,
|
|
867
|
+
createdAt: Temporal.Now.instant().subtract({ minutes: 30 }),
|
|
868
|
+
expiresAt: Temporal.Now.instant().add({ hours: 2 }),
|
|
869
|
+
});
|
|
870
|
+
await seedRow(stack.db, userSessionTable, {
|
|
871
|
+
id: sessionTarget2Id,
|
|
872
|
+
userId: memberUserId,
|
|
873
|
+
tenantId: TENANT_A_ID,
|
|
874
|
+
createdAt: Temporal.Now.instant().subtract({ minutes: 10 }),
|
|
875
|
+
expiresAt: Temporal.Now.instant().add({ hours: 2 }),
|
|
876
|
+
});
|
|
877
|
+
await seedRow(stack.db, userSessionTable, {
|
|
878
|
+
id: sessionAdminId,
|
|
879
|
+
userId: tenantAdminAId,
|
|
880
|
+
tenantId: TENANT_A_ID,
|
|
881
|
+
createdAt: Temporal.Now.instant().subtract({ minutes: 5 }),
|
|
882
|
+
expiresAt: Temporal.Now.instant().add({ hours: 2 }),
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const res = await stack.http.writeOk(
|
|
886
|
+
TenantHandlers.updateMemberRoles,
|
|
887
|
+
{ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] },
|
|
888
|
+
tenantAdminA(),
|
|
889
|
+
);
|
|
890
|
+
expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
|
|
891
|
+
|
|
892
|
+
const [s1] = await selectMany(stack.db, userSessionTable, { id: sessionTarget1Id });
|
|
893
|
+
const [s2] = await selectMany(stack.db, userSessionTable, { id: sessionTarget2Id });
|
|
894
|
+
expect(s1?.revokedAt).not.toBeNull();
|
|
895
|
+
expect(s2?.revokedAt).not.toBeNull();
|
|
896
|
+
|
|
897
|
+
const [sAdmin] = await selectMany(stack.db, userSessionTable, { id: sessionAdminId });
|
|
898
|
+
expect(sAdmin?.revokedAt).toBeNull();
|
|
899
|
+
|
|
900
|
+
// Event-store actor is createSystemUser (handler rewrites via system
|
|
901
|
+
// stream), so filter by type + tenant — not the HTTP caller's user id.
|
|
902
|
+
const membershipEvents = await selectMany(stack.db, eventsTable, {
|
|
903
|
+
aggregateType: "tenant-membership",
|
|
904
|
+
});
|
|
905
|
+
const updateEvent = membershipEvents.find(
|
|
906
|
+
(e) => e.type === "tenant-membership.updated" && e.tenantId === TENANT_A_ID,
|
|
907
|
+
);
|
|
908
|
+
expect(updateEvent).toBeDefined();
|
|
909
|
+
expect(updateEvent?.payload).toMatchObject({
|
|
910
|
+
changes: { roles: JSON.stringify(["Admin"]) },
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
describe("invite + accept end-to-end integration against real stack", () => {
|
|
916
|
+
test("TenantAdmin invites user with Admin role, recipient accepts via invite-signup-complete and gains Admin membership", async () => {
|
|
917
|
+
const inviteeEmail = "invitee-e2e@example.com";
|
|
918
|
+
const inviteRes = (await stack.http.writeOk(
|
|
919
|
+
AuthHandlers.inviteCreate,
|
|
920
|
+
{ email: inviteeEmail, role: "Admin" },
|
|
921
|
+
tenantAdminA(),
|
|
922
|
+
)) as { invitationId: string; email: string };
|
|
923
|
+
|
|
924
|
+
expect(inviteRes.email).toBe(inviteeEmail);
|
|
925
|
+
|
|
926
|
+
const sentMail = emailTransport.sent.find((m) => m.to.includes(inviteeEmail));
|
|
927
|
+
expect(sentMail).toBeDefined();
|
|
928
|
+
const tokenMatch = sentMail?.html?.match(/token=([a-zA-Z0-9_-]+)/);
|
|
929
|
+
expect(tokenMatch?.[1]).toBeDefined();
|
|
930
|
+
const token = tokenMatch![1];
|
|
931
|
+
|
|
932
|
+
const acceptRes = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
933
|
+
token,
|
|
934
|
+
email: inviteeEmail,
|
|
935
|
+
name: "Invited Admin",
|
|
936
|
+
password: "StrongValidPassword123!",
|
|
937
|
+
});
|
|
938
|
+
expect(acceptRes.status).toBe(200);
|
|
939
|
+
const acceptBody = (await acceptRes.json()) as { user?: { id?: string }; token?: string };
|
|
940
|
+
expect(acceptBody.user?.id).toBeDefined();
|
|
941
|
+
const newUserId = acceptBody.user!.id!;
|
|
942
|
+
|
|
943
|
+
const memberships = await selectMany(stack.db, tenantMembershipsTable, {
|
|
944
|
+
userId: newUserId,
|
|
945
|
+
tenantId: TENANT_A_ID,
|
|
946
|
+
});
|
|
947
|
+
expect(memberships).toHaveLength(1);
|
|
948
|
+
expect(JSON.parse(memberships[0]?.roles as string)).toEqual(["Admin"]);
|
|
949
|
+
|
|
950
|
+
const teamRows = await queryTeamList({}, tenantAdminA());
|
|
951
|
+
const pendingInvite = teamRows.find((r) => r.email === inviteeEmail && r.status === "pending");
|
|
952
|
+
expect(pendingInvite).toBeUndefined();
|
|
953
|
+
const activeMember = teamRows.find((r) => r.email === inviteeEmail && r.status === "active");
|
|
954
|
+
expect(activeMember).toBeDefined();
|
|
955
|
+
|
|
956
|
+
const newAdminSession: SessionUser = { id: newUserId, tenantId: TENANT_A_ID, roles: ["Admin"] };
|
|
957
|
+
const membersList = await stack.http.queryOk<readonly { userId: string }[]>(
|
|
958
|
+
TenantQueries.members,
|
|
959
|
+
{},
|
|
960
|
+
newAdminSession,
|
|
961
|
+
);
|
|
962
|
+
expect(membersList.some((m) => m.userId === newUserId)).toBe(true);
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
describe("members UI screens and actions integration against real stack", () => {
|
|
967
|
+
test("members screen and member-roles-edit actionForm execute through stack", async () => {
|
|
968
|
+
const membersScreen = stack.registry.getScreen(`tenant:screen:${MEMBERS_SCREEN_ID}`);
|
|
969
|
+
expect(membersScreen).toBeDefined();
|
|
970
|
+
expect(membersScreen?.type).toBe("projectionList");
|
|
971
|
+
|
|
972
|
+
const editRolesScreen = stack.registry.getScreen(
|
|
973
|
+
`tenant:screen:${MEMBER_ROLES_EDIT_SCREEN_ID}`,
|
|
974
|
+
);
|
|
975
|
+
expect(editRolesScreen).toBeDefined();
|
|
976
|
+
expect(editRolesScreen?.type).toBe("actionForm");
|
|
977
|
+
|
|
978
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
979
|
+
email: "ui-member@example.com",
|
|
980
|
+
displayName: "UI Member",
|
|
981
|
+
});
|
|
982
|
+
await seedTenantMembership(stack.db, {
|
|
983
|
+
userId: memberUserId,
|
|
984
|
+
tenantId: TENANT_A_ID,
|
|
985
|
+
roles: ["User"],
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
const res = await stack.http.writeOk(
|
|
989
|
+
TenantHandlers.updateMemberRoles,
|
|
990
|
+
{ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] },
|
|
991
|
+
tenantAdminA(),
|
|
992
|
+
);
|
|
993
|
+
expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
|
|
566
994
|
});
|
|
567
995
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import { access } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
5
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
6
|
import {
|
|
6
7
|
createTestUser,
|
|
@@ -360,10 +361,10 @@ describe("scenario 7: access rules on handlers", () => {
|
|
|
360
361
|
"SystemAdmin",
|
|
361
362
|
]);
|
|
362
363
|
// updateMemberRoles akzeptiert "system" (für seed-migrations + ops-tooling)
|
|
363
|
-
// PLUS "
|
|
364
|
+
// PLUS access.admin ("TenantAdmin", "Admin", "SystemAdmin").
|
|
364
365
|
expect(
|
|
365
366
|
rolesOf(stack.registry.getWriteHandler(TenantHandlers.updateMemberRoles)?.access),
|
|
366
|
-
).toEqual(["system",
|
|
367
|
+
).toEqual(["system", ...access.admin]);
|
|
367
368
|
expect(rolesOf(stack.registry.getQueryHandler(TenantQueries.list)?.access)).toEqual([
|
|
368
369
|
"SystemAdmin",
|
|
369
370
|
]);
|
package/src/tenant/constants.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const MEMBERS_SCREEN_ID = "members" as const;
|
|
|
11
11
|
|
|
12
12
|
export const INVITE_CREATE_SCREEN_ID = "invite-create" as const;
|
|
13
13
|
|
|
14
|
+
export const MEMBER_ROLES_EDIT_SCREEN_ID = "member-roles-edit" as const;
|
|
15
|
+
|
|
14
16
|
/** Client column-renderer for the /members screen's status column — see
|
|
15
17
|
* `screen.columns[].renderer.react.__component` in screens.ts and
|
|
16
18
|
* `tenantClient()`'s `columnRenderers` map. */
|
|
@@ -53,4 +55,5 @@ export const TenantQueries = {
|
|
|
53
55
|
export const TenantErrors = {
|
|
54
56
|
membershipNotFound: "membership_not_found",
|
|
55
57
|
membershipAlreadyExists: "membership_already_exists",
|
|
58
|
+
lastTenantAdmin: "last_tenant_admin",
|
|
56
59
|
} as const;
|
package/src/tenant/feature.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { tenantEntity } from "./schema/tenant";
|
|
|
30
30
|
import {
|
|
31
31
|
createMembersScreen,
|
|
32
32
|
inviteCreateScreen,
|
|
33
|
+
memberRolesEditScreen,
|
|
33
34
|
tenantEditScreen,
|
|
34
35
|
tenantListScreen,
|
|
35
36
|
} from "./screens";
|
|
@@ -154,10 +155,10 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
|
|
|
154
155
|
r.screen(tenantListScreen);
|
|
155
156
|
r.screen(tenantEditScreen);
|
|
156
157
|
// Tenant-admin team UI: one list (active members + pending invitations,
|
|
157
|
-
// §2.6), invite via a drawer-hosted actionForm
|
|
158
|
-
//
|
|
159
|
-
// handler access.admin.
|
|
158
|
+
// §2.6), invite via a drawer-hosted actionForm, role-edit via
|
|
159
|
+
// actionForm. Screen access matches handler access.admin.
|
|
160
160
|
r.screen(createMembersScreen(options));
|
|
161
|
+
r.screen(memberRolesEditScreen);
|
|
161
162
|
if (options?.inviteScreen) {
|
|
162
163
|
r.screen(inviteCreateScreen);
|
|
163
164
|
}
|
|
@@ -20,6 +20,7 @@ type TeamStatus = "active" | "pending";
|
|
|
20
20
|
|
|
21
21
|
type TeamRow = {
|
|
22
22
|
readonly id: string;
|
|
23
|
+
readonly userId: string | null;
|
|
23
24
|
readonly email: string | null;
|
|
24
25
|
readonly displayName: string | null;
|
|
25
26
|
readonly roles: readonly string[];
|
|
@@ -165,6 +166,7 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
165
166
|
const createdAt = row["createdAt"];
|
|
166
167
|
return {
|
|
167
168
|
id: String(row["id"]),
|
|
169
|
+
userId,
|
|
168
170
|
email: decrypted?.email ?? null,
|
|
169
171
|
displayName: decrypted?.displayName ?? null,
|
|
170
172
|
roles: parseRoles(row["roles"]),
|
|
@@ -197,6 +199,7 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
197
199
|
const expiresAt = row["expiresAt"];
|
|
198
200
|
return {
|
|
199
201
|
id: String(row["id"]),
|
|
202
|
+
userId: null,
|
|
200
203
|
email: decryptedEmail,
|
|
201
204
|
displayName: null,
|
|
202
205
|
roles: typeof row["role"] === "string" ? [row["role"]] : [],
|