@cosmicdrift/kumiko-bundled-features 0.215.6 → 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 -8
- 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/cap-counter/__tests__/cap-list.boot.test.ts +4 -0
- package/src/cap-counter/feature.ts +1 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +139 -1
- package/src/delivery/feature.ts +1 -0
- package/src/delivery/handlers/log.query.ts +12 -3
- package/src/delivery/i18n.ts +1 -0
- package/src/jobs/is-manual-trigger.test.ts +17 -0
- package/src/shared/map-with-concurrency.test.ts +1 -1
- package/src/shared/session-locale-field.test.ts +16 -0
- package/src/shared/session-timezone-field.test.ts +16 -0
- package/src/tenant/__tests__/members-facet-i18n.test.ts +72 -0
- 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,6 +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.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.216.0",
|
|
164
|
+
"@cosmicdrift/kumiko-locale-es": "0.216.0"
|
|
164
165
|
}
|
|
165
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
|
}
|
|
@@ -11,6 +11,10 @@ describe("cap-counter list screen + handler access alignment", () => {
|
|
|
11
11
|
expect(() => validateBoot(features)).not.toThrow();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
test("cap-list nav declares icon gauge", () => {
|
|
15
|
+
expect(capCounterFeature.navs["cap-list"]?.icon).toBe("gauge");
|
|
16
|
+
});
|
|
17
|
+
|
|
14
18
|
test("cap-list screen is entityList, SystemAdmin-gated", () => {
|
|
15
19
|
const screen = capCounterFeature.screens[CAP_COUNTER_LIST_SCREEN_ID];
|
|
16
20
|
expect(screen?.type).toBe("entityList");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { insertMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
-
import { access, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { access, createRegistry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import {
|
|
5
5
|
createTestUser,
|
|
6
6
|
setupTestStack,
|
|
@@ -105,6 +105,144 @@ describe("delivery log tenant isolation", () => {
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
describe("delivery log SystemAdmin cross-tenant scope", () => {
|
|
109
|
+
// Platform waitlist confirmations are dispatched under SYSTEM_TENANT_ID
|
|
110
|
+
// (anonymous submit). SystemAdmin must see those rows even when their
|
|
111
|
+
// session tenant is a demo tenant; TenantAdmin must not.
|
|
112
|
+
const WAITLIST_CONFIRMATION = "waitlist:notify:confirmation";
|
|
113
|
+
|
|
114
|
+
test("SystemAdmin sees waitlist confirmation under SYSTEM_TENANT_ID and other tenants, with tenantId on each row", async () => {
|
|
115
|
+
const demoTenant = testTenantId(51);
|
|
116
|
+
const otherTenant = testTenantId(52);
|
|
117
|
+
const systemConfirmationId = crypto.randomUUID();
|
|
118
|
+
const demoActivationId = crypto.randomUUID();
|
|
119
|
+
const otherTenantId = crypto.randomUUID();
|
|
120
|
+
const failedConfirmationId = crypto.randomUUID();
|
|
121
|
+
|
|
122
|
+
await insertMany(stack.db, deliveryAttemptsTable, [
|
|
123
|
+
{
|
|
124
|
+
id: systemConfirmationId,
|
|
125
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
126
|
+
notificationType: WAITLIST_CONFIRMATION,
|
|
127
|
+
channel: "email",
|
|
128
|
+
recipientAddress: null,
|
|
129
|
+
status: "sent",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: failedConfirmationId,
|
|
133
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
134
|
+
notificationType: WAITLIST_CONFIRMATION,
|
|
135
|
+
channel: "email",
|
|
136
|
+
recipientAddress: null,
|
|
137
|
+
status: "failed",
|
|
138
|
+
error: "smtp down",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: demoActivationId,
|
|
142
|
+
tenantId: demoTenant,
|
|
143
|
+
notificationType: "auth-email-password:signup-activation",
|
|
144
|
+
channel: "email",
|
|
145
|
+
recipientAddress: null,
|
|
146
|
+
status: "sent",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: otherTenantId,
|
|
150
|
+
tenantId: otherTenant,
|
|
151
|
+
notificationType: "welcome",
|
|
152
|
+
channel: "email",
|
|
153
|
+
recipientAddress: null,
|
|
154
|
+
status: "sent",
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
// Session on demo tenant — mirrors SystemAdmin browsing a tenant panel.
|
|
159
|
+
const systemAdmin = createTestUser({
|
|
160
|
+
id: 51,
|
|
161
|
+
roles: ["SystemAdmin"],
|
|
162
|
+
tenantId: demoTenant,
|
|
163
|
+
});
|
|
164
|
+
const res = await stack.http.queryOk<{
|
|
165
|
+
rows: readonly {
|
|
166
|
+
id: string;
|
|
167
|
+
tenantId: string;
|
|
168
|
+
type: string;
|
|
169
|
+
status: string;
|
|
170
|
+
error: string | null;
|
|
171
|
+
}[];
|
|
172
|
+
}>(DeliveryQueries.log, {}, systemAdmin);
|
|
173
|
+
|
|
174
|
+
const byId = new Map(res.rows.map((r) => [r.id, r]));
|
|
175
|
+
expect(byId.get(systemConfirmationId)?.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
176
|
+
expect(byId.get(systemConfirmationId)?.type).toBe(WAITLIST_CONFIRMATION);
|
|
177
|
+
expect(byId.get(demoActivationId)?.tenantId).toBe(demoTenant);
|
|
178
|
+
expect(byId.get(otherTenantId)?.tenantId).toBe(otherTenant);
|
|
179
|
+
|
|
180
|
+
const failed = byId.get(failedConfirmationId);
|
|
181
|
+
expect(failed?.tenantId).toBe(SYSTEM_TENANT_ID);
|
|
182
|
+
expect(failed?.type).toBe(WAITLIST_CONFIRMATION);
|
|
183
|
+
// Failure path: confirmation delivery errors must surface in the log so
|
|
184
|
+
// SystemAdmin can diagnose SMTP/channel outages (status=failed).
|
|
185
|
+
expect(failed?.status).toBe("failed");
|
|
186
|
+
|
|
187
|
+
expect(res.rows.every((r) => typeof r.tenantId === "string" && r.tenantId.length > 0)).toBe(
|
|
188
|
+
true,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("TenantAdmin cannot read SYSTEM_TENANT_ID or foreign-tenant delivery attempts", async () => {
|
|
193
|
+
const demoTenant = testTenantId(61);
|
|
194
|
+
const otherTenant = testTenantId(62);
|
|
195
|
+
await insertMany(stack.db, deliveryAttemptsTable, [
|
|
196
|
+
{
|
|
197
|
+
id: crypto.randomUUID(),
|
|
198
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
199
|
+
notificationType: WAITLIST_CONFIRMATION,
|
|
200
|
+
channel: "email",
|
|
201
|
+
recipientAddress: null,
|
|
202
|
+
status: "sent",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: crypto.randomUUID(),
|
|
206
|
+
tenantId: otherTenant,
|
|
207
|
+
notificationType: "welcome",
|
|
208
|
+
channel: "email",
|
|
209
|
+
recipientAddress: null,
|
|
210
|
+
status: "sent",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: crypto.randomUUID(),
|
|
214
|
+
tenantId: demoTenant,
|
|
215
|
+
notificationType: "welcome",
|
|
216
|
+
channel: "email",
|
|
217
|
+
recipientAddress: null,
|
|
218
|
+
status: "sent",
|
|
219
|
+
},
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const tenantAdmin = createTestUser({
|
|
223
|
+
id: 61,
|
|
224
|
+
roles: ["TenantAdmin"],
|
|
225
|
+
tenantId: demoTenant,
|
|
226
|
+
});
|
|
227
|
+
const res = await stack.http.queryOk<{ rows: readonly { tenantId: string }[] }>(
|
|
228
|
+
DeliveryQueries.log,
|
|
229
|
+
{},
|
|
230
|
+
tenantAdmin,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(res.rows.length).toBeGreaterThan(0);
|
|
234
|
+
expect(res.rows.every((r) => r.tenantId === demoTenant)).toBe(true);
|
|
235
|
+
expect(res.rows.some((r) => r.tenantId === SYSTEM_TENANT_ID)).toBe(false);
|
|
236
|
+
expect(res.rows.some((r) => r.tenantId === otherTenant)).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("tenant User cannot query delivery log (negative authz)", async () => {
|
|
240
|
+
const demoTenant = testTenantId(71);
|
|
241
|
+
const user = createTestUser({ id: 71, roles: ["User"], tenantId: demoTenant });
|
|
242
|
+
expect((await stack.http.query(DeliveryQueries.log, {}, user)).status).toBe(403);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
108
246
|
describe("delivery preferences tenant isolation", () => {
|
|
109
247
|
// A userId is not tenant-unique — the same person can be a member of
|
|
110
248
|
// several tenants (tenant:write:add-member takes {userId, tenantId}).
|
package/src/delivery/feature.ts
CHANGED
|
@@ -143,6 +143,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
143
143
|
type: "projectionList",
|
|
144
144
|
query: DeliveryQueries.log,
|
|
145
145
|
columns: [
|
|
146
|
+
{ field: "tenantId", label: "delivery.log.col.tenantId" },
|
|
146
147
|
{ field: "type", label: "delivery.log.col.type" },
|
|
147
148
|
{ field: "channel", label: "delivery.log.col.channel" },
|
|
148
149
|
{ field: "recipient", label: "delivery.log.col.recipient" },
|
|
@@ -81,8 +81,15 @@ export const logQuery = definePagedQueryHandler({
|
|
|
81
81
|
// delivery is r.systemScope()'d, so the TenantDb is system-mode (reads
|
|
82
82
|
// unfiltered). assertTenantMatch is a self-check on the caller's own
|
|
83
83
|
// tenantId, not a query filter — it returns that same unfiltered db, so
|
|
84
|
-
// the explicit `where` below still does the actual tenant scoping
|
|
85
|
-
|
|
84
|
+
// the explicit `where` below still does the actual tenant scoping for
|
|
85
|
+
// non-SystemAdmin callers. SystemAdmin acknowledges cross-tenant read
|
|
86
|
+
// (PII-bearing delivery logs across all tenants incl. SYSTEM_TENANT_ID).
|
|
87
|
+
const isSystemAdmin = query.user.roles.includes("SystemAdmin");
|
|
88
|
+
const db = isSystemAdmin
|
|
89
|
+
? ctx.systemDb.acknowledgeCrossTenant(
|
|
90
|
+
"SystemAdmin reads delivery attempts across all tenants including SYSTEM_TENANT_ID",
|
|
91
|
+
)
|
|
92
|
+
: ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
86
93
|
|
|
87
94
|
const requestedSort = query.payload.sort;
|
|
88
95
|
const sortField: DeliveryLogSortField =
|
|
@@ -92,7 +99,9 @@ export const logQuery = definePagedQueryHandler({
|
|
|
92
99
|
const sortDirection = query.payload.sortDirection ?? "desc";
|
|
93
100
|
const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
|
|
94
101
|
|
|
95
|
-
|
|
102
|
+
// TenantAdmin/Admin stay strictly tenant-scoped; SystemAdmin sees every
|
|
103
|
+
// tenant's attempts (platform waitlist confirmations live on SYSTEM_TENANT_ID).
|
|
104
|
+
const where: WhereObject = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
|
|
96
105
|
if (query.payload.cursor) {
|
|
97
106
|
where[sortColumn] = {
|
|
98
107
|
[sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
|
package/src/delivery/i18n.ts
CHANGED
|
@@ -3,6 +3,7 @@ type LocalizedString = { readonly en: string };
|
|
|
3
3
|
export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
4
|
"screen:delivery-log.title": { en: "Delivery log" },
|
|
5
5
|
"delivery:nav.deliveryLog": { en: "Delivery" },
|
|
6
|
+
"delivery.log.col.tenantId": { en: "Tenant" },
|
|
6
7
|
"delivery.log.col.type": { en: "Type" },
|
|
7
8
|
"delivery.log.col.channel": { en: "Channel" },
|
|
8
9
|
"delivery.log.col.recipient": { en: "Recipient" },
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isManualTrigger } from "./is-manual-trigger";
|
|
3
|
+
|
|
4
|
+
describe("isManualTrigger", () => {
|
|
5
|
+
test("manual trigger → true", () => {
|
|
6
|
+
expect(isManualTrigger({ manual: true })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("cron trigger → false", () => {
|
|
10
|
+
expect(isManualTrigger({ cron: "*/5 * * * *" })).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("event trigger → false", () => {
|
|
14
|
+
expect(isManualTrigger({ on: "entity.created" })).toBe(false);
|
|
15
|
+
expect(isManualTrigger({ on: ["entity.created", "entity.updated"] })).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sessionLocaleField } from "./session-locale-field";
|
|
3
|
+
|
|
4
|
+
describe("sessionLocaleField", () => {
|
|
5
|
+
test("set locale → { locale }", () => {
|
|
6
|
+
expect(sessionLocaleField("de-DE")).toEqual({ locale: "de-DE" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("null locale → empty object", () => {
|
|
10
|
+
expect(sessionLocaleField(null)).toEqual({});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("undefined locale → empty object", () => {
|
|
14
|
+
expect(sessionLocaleField(undefined)).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sessionTimezoneField } from "./session-timezone-field";
|
|
3
|
+
|
|
4
|
+
describe("sessionTimezoneField", () => {
|
|
5
|
+
test("set timezone → { timezone }", () => {
|
|
6
|
+
expect(sessionTimezoneField("Europe/Berlin")).toEqual({ timezone: "Europe/Berlin" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("null timezone → empty object", () => {
|
|
10
|
+
expect(sessionTimezoneField(null)).toEqual({});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("undefined timezone → empty object", () => {
|
|
14
|
+
expect(sessionTimezoneField(undefined)).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
});
|