@cosmicdrift/kumiko-bundled-features 0.215.7 → 0.216.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/handlers/invite-create.write.ts +12 -3
- 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__/user.integration.test.ts +560 -3
- package/src/user/constants.ts +3 -0
- package/src/user/handlers/create.write.ts +36 -17
- package/src/user/handlers/update-roles.ts +169 -0
- package/src/user/handlers/update.write.ts +32 -8
- package/src/user/i18n.ts +1 -0
- package/src/user/screens.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +43 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.216.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.216.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.216.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.216.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.216.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.216.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.216.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
164
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.216.0",
|
|
164
|
+
"@cosmicdrift/kumiko-locale-es": "0.216.0"
|
|
165
165
|
}
|
|
166
166
|
}
|
|
@@ -24,7 +24,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
24
24
|
import { randomBytes } from "node:crypto";
|
|
25
25
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
26
26
|
import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
|
|
27
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
27
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
28
28
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
29
29
|
import {
|
|
30
30
|
setupTestStack,
|
|
@@ -54,7 +54,7 @@ import { createTenantFeature } from "../tenant";
|
|
|
54
54
|
import { TenantHandlers } from "../tenant/constants";
|
|
55
55
|
import { tenantMembershipsTable } from "../tenant/membership-table";
|
|
56
56
|
import { tenantEntity } from "../tenant/schema/tenant";
|
|
57
|
-
import { UserHandlers } from "../user";
|
|
57
|
+
import { UserHandlers, UserQueries } from "../user";
|
|
58
58
|
import { createUserFeature } from "../user/feature";
|
|
59
59
|
import { userEntity, userTable } from "../user/schema/user";
|
|
60
60
|
|
|
@@ -276,4 +276,37 @@ describe("access-invalidation event consumer", () => {
|
|
|
276
276
|
|
|
277
277
|
expect(invalidated).toEqual([userId]);
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
test("user:update (global role change) revokes sessions and pushes an invalidation", async () => {
|
|
281
|
+
const { userId } = await h.seedUser("globalrolechange@example.com", "pw-long-enough");
|
|
282
|
+
const { sid } = await h.login("globalrolechange@example.com", "pw-long-enough");
|
|
283
|
+
trackInvalidation(userId);
|
|
284
|
+
|
|
285
|
+
const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
|
|
286
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
287
|
+
UserQueries.detail,
|
|
288
|
+
{ id: userId },
|
|
289
|
+
admin,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await stack.http.writeOk(
|
|
293
|
+
UserHandlers.update,
|
|
294
|
+
{
|
|
295
|
+
id: userId,
|
|
296
|
+
version: detail["version"],
|
|
297
|
+
changes: { roles: JSON.stringify(["SystemAdmin"]) },
|
|
298
|
+
},
|
|
299
|
+
admin,
|
|
300
|
+
);
|
|
301
|
+
expect(result).toBeTruthy();
|
|
302
|
+
|
|
303
|
+
await stack.eventDispatcher?.runOnce();
|
|
304
|
+
|
|
305
|
+
expect(invalidated).toEqual([userId]);
|
|
306
|
+
|
|
307
|
+
const sessionRow = await fetchOne<{ revokedAt: unknown }>(stack.db, userSessionTable, {
|
|
308
|
+
id: sid,
|
|
309
|
+
});
|
|
310
|
+
expect(sessionRow?.revokedAt).not.toBeNull();
|
|
311
|
+
});
|
|
279
312
|
});
|
|
@@ -660,10 +660,9 @@ describe("invitations-query (pending list)", () => {
|
|
|
660
660
|
// merges flat into the session and unlocks the SystemAdmin-gated cross-tenant
|
|
661
661
|
// handler surface (hasAccess can't tell membership roles from global ones).
|
|
662
662
|
describe("privilege escalation via invite role", () => {
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
// passed every SystemAdmin gate cross-tenant.
|
|
663
|
+
// Reserved / global roles: must NEVER reach a tenant invitation. hasAccess
|
|
664
|
+
// is flat, so a tenant-member invite for SystemAdmin would escalate on
|
|
665
|
+
// login/switch without any cross-tenant check.
|
|
667
666
|
const FORBIDDEN_ROLES = ["SystemAdmin", "system", "all", "anonymous"];
|
|
668
667
|
|
|
669
668
|
test("invite-create rejects reserved/global roles — no invitation persisted, no mail", async () => {
|
|
@@ -673,49 +672,100 @@ describe("privilege escalation via invite role", () => {
|
|
|
673
672
|
{ email: CAROL_EMAIL, role },
|
|
674
673
|
aliceSession(),
|
|
675
674
|
);
|
|
676
|
-
expect(err.
|
|
675
|
+
expect(err.details).toMatchObject({ reason: "reserved_membership_role", role });
|
|
677
676
|
const rows = await selectMany(stack.db, tenantInvitationsTable, { email: CAROL_EMAIL });
|
|
678
677
|
expect(rows).toHaveLength(0);
|
|
678
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
679
679
|
}
|
|
680
|
-
// The forbidden-role check fires before the mail dispatch.
|
|
681
|
-
expect(emailTransport.sent).toHaveLength(0);
|
|
682
680
|
});
|
|
683
681
|
|
|
684
682
|
test("legitimate tenant role still issues an invitation", async () => {
|
|
685
683
|
const result = (await stack.http.writeOk(
|
|
686
684
|
AuthHandlers.inviteCreate,
|
|
687
|
-
{ email: CAROL_EMAIL, role: "
|
|
685
|
+
{ email: CAROL_EMAIL, role: "Admin" },
|
|
688
686
|
aliceSession(),
|
|
689
687
|
)) as { role: string };
|
|
690
|
-
expect(result.role).toBe("
|
|
688
|
+
expect(result.role).toBe("Admin");
|
|
691
689
|
});
|
|
692
690
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
691
|
+
test("TenantAdmin can invite TenantAdmin, Admin, Editor, and User", async () => {
|
|
692
|
+
const tenantAdminSession: SessionUser = {
|
|
693
|
+
id: aliceId,
|
|
694
|
+
tenantId: TENANT_A_ID,
|
|
695
|
+
roles: ["TenantAdmin"],
|
|
696
|
+
};
|
|
697
|
+
for (const role of ["TenantAdmin", "Admin", "Editor", "User"]) {
|
|
698
|
+
const email = `target-${role.toLowerCase()}@example.com`;
|
|
699
|
+
const result = (await stack.http.writeOk(
|
|
700
|
+
AuthHandlers.inviteCreate,
|
|
701
|
+
{ email, role },
|
|
702
|
+
tenantAdminSession,
|
|
703
|
+
)) as { role: string };
|
|
704
|
+
expect(result.role).toBe(role);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("Admin cannot invite TenantAdmin (elevation guard default)", async () => {
|
|
709
|
+
const err = await stack.http.writeErr(
|
|
699
710
|
AuthHandlers.inviteCreate,
|
|
700
|
-
{ email: "
|
|
711
|
+
{ email: "elevate-target@example.com", role: "TenantAdmin" },
|
|
712
|
+
aliceSession(), // roles: ["Admin"]
|
|
713
|
+
);
|
|
714
|
+
expect(err.details).toMatchObject({
|
|
715
|
+
reason: "unassignable_membership_role",
|
|
716
|
+
role: "TenantAdmin",
|
|
717
|
+
});
|
|
718
|
+
const rows = await selectMany(stack.db, tenantInvitationsTable, {
|
|
719
|
+
email: "elevate-target@example.com",
|
|
720
|
+
});
|
|
721
|
+
expect(rows).toHaveLength(0);
|
|
722
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("TenantAdmin cannot invite SystemAdmin (rejected by reserved role guard)", async () => {
|
|
726
|
+
const tenantAdminSession: SessionUser = {
|
|
727
|
+
id: aliceId,
|
|
728
|
+
tenantId: TENANT_A_ID,
|
|
729
|
+
roles: ["TenantAdmin"],
|
|
730
|
+
};
|
|
731
|
+
const err = await stack.http.writeErr(
|
|
732
|
+
AuthHandlers.inviteCreate,
|
|
733
|
+
{ email: "sysadmin-target@example.com", role: "SystemAdmin" },
|
|
734
|
+
tenantAdminSession,
|
|
735
|
+
);
|
|
736
|
+
expect(err.details).toMatchObject({
|
|
737
|
+
reason: "reserved_membership_role",
|
|
738
|
+
role: "SystemAdmin",
|
|
739
|
+
});
|
|
740
|
+
const rows = await selectMany(stack.db, tenantInvitationsTable, {
|
|
741
|
+
email: "sysadmin-target@example.com",
|
|
742
|
+
});
|
|
743
|
+
expect(rows).toHaveLength(0);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test("rejects unknown/unranked roles fail-closed", async () => {
|
|
747
|
+
const err = await stack.http.writeErr(
|
|
748
|
+
AuthHandlers.inviteCreate,
|
|
749
|
+
{ email: "unknown-role@example.com", role: "CustomRole" },
|
|
701
750
|
aliceSession(),
|
|
702
|
-
)
|
|
703
|
-
expect(
|
|
751
|
+
);
|
|
752
|
+
expect(err.details).toMatchObject({
|
|
753
|
+
reason: "unassignable_membership_role",
|
|
754
|
+
role: "CustomRole",
|
|
755
|
+
});
|
|
704
756
|
});
|
|
705
757
|
});
|
|
706
758
|
|
|
707
|
-
// canAssignRole
|
|
708
|
-
//
|
|
709
|
-
// invite feature WITH the hook to prove it's actually enforced, distinct from
|
|
710
|
-
// the framework-reserved-role check covered above.
|
|
759
|
+
// canAssignRole further restricts ranked roles after the default elevation
|
|
760
|
+
// guard — a separate stack mounts the invite feature WITH the hook.
|
|
711
761
|
describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
|
|
712
762
|
let gateStack: TestStack;
|
|
713
763
|
let gateAliceId: string;
|
|
714
764
|
let gateTenantId: TenantId;
|
|
715
765
|
const gateTransport = createInMemoryTransport();
|
|
716
766
|
|
|
717
|
-
function gateAliceSession(): SessionUser {
|
|
718
|
-
return { id: gateAliceId, tenantId: gateTenantId, roles: [
|
|
767
|
+
function gateAliceSession(roles: readonly string[] = ["TenantAdmin"]): SessionUser {
|
|
768
|
+
return { id: gateAliceId, tenantId: gateTenantId, roles: [...roles] };
|
|
719
769
|
}
|
|
720
770
|
|
|
721
771
|
beforeAll(async () => {
|
|
@@ -737,11 +787,9 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
|
|
|
737
787
|
invite: {
|
|
738
788
|
tokenTtlMinutes: 60,
|
|
739
789
|
appUrl: APP_ACCEPT_URL,
|
|
740
|
-
//
|
|
741
|
-
// non-DPO roles. A minimal, deliberately app-shaped hierarchy;
|
|
742
|
-
// the framework itself knows nothing about "DPO".
|
|
790
|
+
// Custom app policy: only TenantAdmin with special flag may grant "Admin"
|
|
743
791
|
canAssignRole: (inviterRoles, targetRole) =>
|
|
744
|
-
targetRole !== "
|
|
792
|
+
targetRole !== "Admin" || inviterRoles.includes("SpecialAdmin"),
|
|
745
793
|
},
|
|
746
794
|
}),
|
|
747
795
|
],
|
|
@@ -784,7 +832,7 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
|
|
|
784
832
|
await seedTenantMembership(gateStack.db, {
|
|
785
833
|
userId: gateAliceId,
|
|
786
834
|
tenantId: gateTenantId,
|
|
787
|
-
roles: ["
|
|
835
|
+
roles: ["TenantAdmin"],
|
|
788
836
|
});
|
|
789
837
|
});
|
|
790
838
|
|
|
@@ -793,14 +841,14 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
|
|
|
793
841
|
});
|
|
794
842
|
|
|
795
843
|
test("canAssignRole → false blocks invite-create with a role-hierarchy error, no invitation persisted", async () => {
|
|
796
|
-
// TenantAdmin clears the handler's own access.admin gate
|
|
797
|
-
//
|
|
844
|
+
// TenantAdmin clears the handler's own access.admin gate and default elevation
|
|
845
|
+
// guard for "Admin", but canAssignRole blocks "Admin" because Alice lacks "SpecialAdmin".
|
|
798
846
|
const err = await gateStack.http.writeErr(
|
|
799
847
|
AuthHandlers.inviteCreate,
|
|
800
|
-
{ email: "blocked-target@example.com", role: "
|
|
801
|
-
|
|
848
|
+
{ email: "blocked-target@example.com", role: "Admin" },
|
|
849
|
+
gateAliceSession(),
|
|
802
850
|
);
|
|
803
|
-
expect(err.details).toMatchObject({ reason: "unassignable_membership_role", role: "
|
|
851
|
+
expect(err.details).toMatchObject({ reason: "unassignable_membership_role", role: "Admin" });
|
|
804
852
|
const rows = await selectMany(gateStack.db, tenantInvitationsTable, {
|
|
805
853
|
email: "blocked-target@example.com",
|
|
806
854
|
});
|
|
@@ -809,11 +857,12 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
|
|
|
809
857
|
});
|
|
810
858
|
|
|
811
859
|
test("canAssignRole → true allows invite-create through", async () => {
|
|
860
|
+
// Elevation alone would allow TenantAdmin → Admin; hook requires SpecialAdmin.
|
|
812
861
|
const result = (await gateStack.http.writeOk(
|
|
813
862
|
AuthHandlers.inviteCreate,
|
|
814
|
-
{ email: "allowed-target@example.com", role: "
|
|
815
|
-
gateAliceSession(),
|
|
863
|
+
{ email: "allowed-target@example.com", role: "Admin" },
|
|
864
|
+
gateAliceSession(["TenantAdmin", "SpecialAdmin"]),
|
|
816
865
|
)) as { role: string };
|
|
817
|
-
expect(result.role).toBe("
|
|
866
|
+
expect(result.role).toBe("Admin");
|
|
818
867
|
});
|
|
819
868
|
});
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
// kumiko-lint-ignore cross-feature-import membership-role validation owned by tenant-feature
|
|
34
34
|
import {
|
|
35
35
|
findForbiddenMembershipRole,
|
|
36
|
+
findForbiddenRoleAssignment,
|
|
36
37
|
reservedMembershipRoleError,
|
|
37
38
|
unassignableMembershipRoleError,
|
|
38
39
|
} from "../../tenant/membership-roles";
|
|
@@ -71,9 +72,10 @@ export type InviteCreateOptions = {
|
|
|
71
72
|
* carries no locale signal (no X-Locale header, no usable
|
|
72
73
|
* Accept-Language) — see the locale resolution in the handler below. */
|
|
73
74
|
readonly locale?: AuthMailLocale;
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
75
|
+
// Optional app policy on top of the default elevation guard
|
|
76
|
+
// (findForbiddenRoleAssignment). Cannot authorize reserved or unranked
|
|
77
|
+
// roles, and cannot raise above the inviter's max framework rank — only
|
|
78
|
+
// further restrict assignable ranked roles.
|
|
77
79
|
readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
|
|
78
80
|
};
|
|
79
81
|
|
|
@@ -101,6 +103,13 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
101
103
|
return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [
|
|
107
|
+
event.payload.role,
|
|
108
|
+
]);
|
|
109
|
+
if (elevationForbidden !== undefined) {
|
|
110
|
+
return writeFailure(unassignableMembershipRoleError(elevationForbidden));
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
|
|
105
114
|
return writeFailure(unassignableMembershipRoleError(event.payload.role));
|
|
106
115
|
}
|
|
@@ -68,7 +68,7 @@ describe("mapWithConcurrency", () => {
|
|
|
68
68
|
// for a real unhandled rejection (if any) to fire.
|
|
69
69
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
70
70
|
} finally {
|
|
71
|
-
process.off("unhandledRejection", onUnhandledRejection);
|
|
71
|
+
process.off("unhandledRejection" as never, onUnhandledRejection);
|
|
72
72
|
}
|
|
73
73
|
expect(unhandled).toEqual([]);
|
|
74
74
|
});
|
|
@@ -4,7 +4,9 @@ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
|
4
4
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
5
5
|
import { createConfigFeature } from "../../config/feature";
|
|
6
6
|
import {
|
|
7
|
+
DEFAULT_INVITE_ROLE_OPTIONS,
|
|
7
8
|
INVITE_CREATE_SCREEN_ID,
|
|
9
|
+
MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
8
10
|
MEMBERS_SCREEN_ID,
|
|
9
11
|
TenantHandlers,
|
|
10
12
|
TenantQueries,
|
|
@@ -64,6 +66,37 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
64
66
|
expect(rowAction.visible).toEqual({ field: "status", eq: "pending" });
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
test("edit-roles row action is only visible on active rows and navigates to member-roles-edit", () => {
|
|
70
|
+
const tenant = createTenantFeature();
|
|
71
|
+
const screen = tenant.screens[MEMBERS_SCREEN_ID];
|
|
72
|
+
if (screen?.type !== "projectionList")
|
|
73
|
+
throw new Error("expected members screen to be projectionList");
|
|
74
|
+
const rowAction = screen.rowActions?.find((a) => a.id === "edit-roles");
|
|
75
|
+
if (rowAction?.kind !== "navigate") throw new Error("expected a navigate row action");
|
|
76
|
+
expect(rowAction.screen).toBe(MEMBER_ROLES_EDIT_SCREEN_ID);
|
|
77
|
+
expect(rowAction.params).toEqual({ map: { userId: "userId" } });
|
|
78
|
+
expect(rowAction.visible).toEqual({ field: "status", eq: "active" });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("member-roles-edit actionForm screen is registered and bound to updateMemberRoles, access.admin-gated", () => {
|
|
82
|
+
const tenant = createTenantFeature();
|
|
83
|
+
const screen = tenant.screens[MEMBER_ROLES_EDIT_SCREEN_ID];
|
|
84
|
+
if (screen?.type !== "actionForm")
|
|
85
|
+
throw new Error("expected member-roles-edit screen to be actionForm");
|
|
86
|
+
expect(screen.handler).toBe(TenantHandlers.updateMemberRoles);
|
|
87
|
+
expect(screen.fields["userId"]).toEqual({ type: "text", required: true });
|
|
88
|
+
expect(screen.fields["roles"]).toEqual({
|
|
89
|
+
type: "multiSelect",
|
|
90
|
+
options: DEFAULT_INVITE_ROLE_OPTIONS,
|
|
91
|
+
required: true,
|
|
92
|
+
});
|
|
93
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
94
|
+
expect(rolesOf(screen.access)).toEqual([...access.admin]);
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error("expected member-roles-edit screen to have access.roles");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
67
100
|
// The generic drawer-open/submit/close/refetch mechanics for kind:"drawer"
|
|
68
101
|
// toolbar actions are already covered end-to-end with a synthetic screen in
|
|
69
102
|
// renderer-web's projection-list-actions.test.tsx — this only proves OUR
|
|
@@ -103,6 +136,10 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
103
136
|
expect(rolesOf(tenant.queryHandlers["invitations"]?.access)).toEqual(adminRoles);
|
|
104
137
|
expect(rolesOf(tenant.queryHandlers["team:list"]?.access)).toEqual(adminRoles);
|
|
105
138
|
expect(rolesOf(tenant.writeHandlers["cancel-invitation"]?.access)).toEqual(adminRoles);
|
|
139
|
+
expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
|
|
140
|
+
"system",
|
|
141
|
+
...adminRoles,
|
|
142
|
+
]);
|
|
106
143
|
// invite-create lives on auth feature — checked in tenant-security.integration.test.ts
|
|
107
144
|
void AuthHandlers;
|
|
108
145
|
void TenantHandlers;
|
|
@@ -114,11 +151,11 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
114
151
|
expect(nav?.label).toBe("tenant.nav.members");
|
|
115
152
|
});
|
|
116
153
|
|
|
117
|
-
test("updateMemberRoles
|
|
154
|
+
test("updateMemberRoles allows system and access.admin", () => {
|
|
118
155
|
const tenant = createTenantFeature();
|
|
119
156
|
expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
|
|
120
157
|
"system",
|
|
121
|
-
|
|
158
|
+
...access.admin,
|
|
122
159
|
]);
|
|
123
160
|
});
|
|
124
161
|
});
|
|
@@ -13,13 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
15
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
-
import type
|
|
16
|
+
import { SYSTEM_USER_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
17
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
18
|
import {
|
|
19
19
|
createTestUser,
|
|
20
20
|
setupTestStack,
|
|
21
21
|
type TestStack,
|
|
22
|
-
TestUsers,
|
|
23
22
|
unsafeCreateEntityTable,
|
|
24
23
|
unsafePushTables,
|
|
25
24
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
@@ -200,9 +199,9 @@ describe("seedTenantMembership", () => {
|
|
|
200
199
|
});
|
|
201
200
|
|
|
202
201
|
test("records the `by` user as insertedById on the projection", async () => {
|
|
203
|
-
// Audit-queries that join events → users need a stable actor.
|
|
204
|
-
//
|
|
205
|
-
//
|
|
202
|
+
// Audit-queries that join events → users need a stable actor. Override
|
|
203
|
+
// default createSystemUser(tenantId) with a custom test user and assert
|
|
204
|
+
// it propagates to the projection's inserted_by_id column.
|
|
206
205
|
const seedActor = createTestUser({ id: 99, tenantId: TENANT_A });
|
|
207
206
|
await seedTenantMembership(stack.db, {
|
|
208
207
|
userId: ALICE_ID,
|
|
@@ -215,15 +214,18 @@ describe("seedTenantMembership", () => {
|
|
|
215
214
|
expect(row?.["insertedById"]).toBe(seedActor.id);
|
|
216
215
|
});
|
|
217
216
|
|
|
218
|
-
test("default `by` is
|
|
219
|
-
// Documents the fallback — a regression that changed the default would
|
|
220
|
-
// silently skew audit queries across 18 call-sites.
|
|
217
|
+
test("default `by` is createSystemUser(tenantId)", async () => {
|
|
221
218
|
await seedTenantMembership(stack.db, {
|
|
222
219
|
userId: ALICE_ID,
|
|
223
220
|
tenantId: TENANT_A,
|
|
224
221
|
roles: ["User"],
|
|
225
222
|
});
|
|
226
223
|
const [row] = await selectMany(stack.db, tenantMembershipsTable, { userId: ALICE_ID });
|
|
227
|
-
expect(row?.["insertedById"]).toBe(
|
|
224
|
+
expect(row?.["insertedById"]).toBe(SYSTEM_USER_ID);
|
|
225
|
+
|
|
226
|
+
const events = await selectMany(stack.db, eventsTable, { aggregateType: "tenant-membership" });
|
|
227
|
+
const created = events.filter((e) => e.type === "tenant-membership.created");
|
|
228
|
+
expect(created).toHaveLength(1);
|
|
229
|
+
expect(created[0]?.tenantId).toBe(TENANT_A);
|
|
228
230
|
});
|
|
229
231
|
});
|