@cosmicdrift/kumiko-bundled-features 0.173.0 → 0.173.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.173.0",
3
+ "version": "0.173.1",
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>",
@@ -119,12 +119,12 @@
119
119
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
120
120
  },
121
121
  "dependencies": {
122
- "@cosmicdrift/kumiko-dispatcher-live": "0.173.0",
123
- "@cosmicdrift/kumiko-framework": "0.173.0",
124
- "@cosmicdrift/kumiko-headless": "0.173.0",
125
- "@cosmicdrift/kumiko-renderer": "0.173.0",
126
- "@cosmicdrift/kumiko-renderer-web": "0.173.0",
127
- "@cosmicdrift/kumiko-types": "0.173.0",
122
+ "@cosmicdrift/kumiko-dispatcher-live": "0.173.1",
123
+ "@cosmicdrift/kumiko-framework": "0.173.1",
124
+ "@cosmicdrift/kumiko-headless": "0.173.1",
125
+ "@cosmicdrift/kumiko-renderer": "0.173.1",
126
+ "@cosmicdrift/kumiko-renderer-web": "0.173.1",
127
+ "@cosmicdrift/kumiko-types": "0.173.1",
128
128
  "@mollie/api-client": "^4.5.0",
129
129
  "imapflow": "^1.3.3",
130
130
  "mailparser": "^3.9.8",
@@ -111,6 +111,33 @@ describe("seedAdmin", () => {
111
111
  expect(betaMembership[0]?.["roles"]).toBe(JSON.stringify(["User"]));
112
112
  });
113
113
 
114
+ test("emailVerified: true wird an seedUserWithPassword durchgereicht", async () => {
115
+ await seedAdmin(stack.db, {
116
+ email: "verified@example.com",
117
+ password: "secret-pw",
118
+ displayName: "Verified Admin",
119
+ emailVerified: true,
120
+ memberships: [
121
+ { tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
122
+ ],
123
+ });
124
+ const [user] = await selectMany(stack.db, userTable, { email: "verified@example.com" });
125
+ expect(user?.["emailVerified"]).toBe(true);
126
+ });
127
+
128
+ test("emailVerified: ohne Flag bleibt der User unverifiziert (Default)", async () => {
129
+ await seedAdmin(stack.db, {
130
+ email: "unverified@example.com",
131
+ password: "secret-pw",
132
+ displayName: "Unverified Admin",
133
+ memberships: [
134
+ { tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
135
+ ],
136
+ });
137
+ const [user] = await selectMany(stack.db, userTable, { email: "unverified@example.com" });
138
+ expect(user?.["emailVerified"]).toBe(false);
139
+ });
140
+
114
141
  test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
115
142
  // Erstaufruf
116
143
  const { id: userId1 } = await seedAdmin(stack.db, {
@@ -10,9 +10,25 @@
10
10
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
11
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
12
  import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
13
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import {
14
+ buildEntityTable,
15
+ createEventStoreExecutor,
16
+ createTenantDb,
17
+ } from "@cosmicdrift/kumiko-framework/db";
18
+ import {
19
+ createEntity,
20
+ createTextField,
21
+ defineFeature,
22
+ type TenantId,
23
+ } from "@cosmicdrift/kumiko-framework/engine";
14
24
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
- import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
25
+ import {
26
+ setupTestStack,
27
+ type TestStack,
28
+ TestUsers,
29
+ testTenantId,
30
+ unsafeCreateEntityTable,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
16
32
  import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
17
33
  import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
18
34
  import { createCryptoShreddingFeature } from "../feature";
@@ -142,3 +158,128 @@ describe("crypto-shredding :: forget-subject", () => {
142
158
  expect(err.httpStatus).toBe(400);
143
159
  });
144
160
  });
161
+
162
+ // fw#1611: forget-subject's ctx.searchAdapter wiring (forget-subject.write.ts
163
+ // → purgeSearchDocumentsForSubject) had zero end-to-end coverage — a broken
164
+ // extension registration would silently turn the whole GDPR search purge
165
+ // into a no-op. Own stack (own entity feature) so it doesn't perturb the
166
+ // scenarios above.
167
+ describe("crypto-shredding :: forget-subject purges the derived search index (#1611)", () => {
168
+ const probeEntity = createEntity({
169
+ table: "read_forget_subject_search_probe",
170
+ fields: {
171
+ ownerId: createTextField({ required: true }),
172
+ userNote: createTextField({
173
+ required: true,
174
+ maxLength: 100,
175
+ userOwned: { ownerField: "ownerId" },
176
+ searchable: true,
177
+ }),
178
+ tenantNote: createTextField({
179
+ required: true,
180
+ maxLength: 100,
181
+ tenantOwned: true,
182
+ searchable: true,
183
+ }),
184
+ },
185
+ });
186
+ const probeTable = buildEntityTable("forgetSubjectSearchProbe", probeEntity);
187
+ const probeFeature = defineFeature("forget-subject-search-probe", (r) => {
188
+ r.entity("probe", probeEntity);
189
+ });
190
+
191
+ let searchStack: TestStack;
192
+ let searchKms: InMemoryKmsAdapter;
193
+ const admin = TestUsers.admin;
194
+
195
+ beforeAll(async () => {
196
+ searchStack = await setupTestStack({
197
+ features: [createCryptoShreddingFeature(), probeFeature],
198
+ });
199
+ await unsafeCreateEntityTable(searchStack.db, probeEntity, "probe");
200
+ await createEventsTable(searchStack.db);
201
+ });
202
+
203
+ afterAll(async () => {
204
+ await searchStack.cleanup();
205
+ });
206
+
207
+ beforeEach(() => {
208
+ searchKms = new InMemoryKmsAdapter();
209
+ configurePiiSubjectKms(searchKms);
210
+ });
211
+
212
+ afterEach(() => {
213
+ resetPiiSubjectKmsForTests();
214
+ });
215
+
216
+ function probeExecutor() {
217
+ return createEventStoreExecutor(probeTable, probeEntity, {
218
+ entityName: "probe",
219
+ searchAdapter: searchStack.search,
220
+ });
221
+ }
222
+
223
+ function probeTenantDb() {
224
+ return createTenantDb(searchStack.db, admin.tenantId, "system");
225
+ }
226
+
227
+ test("forget-subject over HTTP purges userOwned rows from the search index", async () => {
228
+ const ownerId = crypto.randomUUID();
229
+ const userNote = "UniqueUserOwnedNote1611";
230
+ const created = await probeExecutor().create(
231
+ { ownerId, userNote, tenantNote: "irrelevant-tenant-note" },
232
+ admin,
233
+ probeTenantDb(),
234
+ );
235
+ if (!created.isSuccess) throw new Error("create failed");
236
+ const id = String(created.data.id);
237
+
238
+ await searchStack.eventDispatcher?.runOnce();
239
+ expect(
240
+ (await searchStack.search.search(admin.tenantId, userNote, { filterType: "probe" })).some(
241
+ (h) => String(h.entityId) === id,
242
+ ),
243
+ ).toBe(true);
244
+
245
+ await searchStack.http.writeOk(
246
+ FORGET,
247
+ { subject: { kind: "user", userId: ownerId }, reason: REASON },
248
+ dpoUser,
249
+ );
250
+
251
+ const after = await searchStack.search.search(admin.tenantId, userNote, {
252
+ filterType: "probe",
253
+ });
254
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
255
+ });
256
+
257
+ test("forget-subject over HTTP purges tenantOwned rows from the search index", async () => {
258
+ const tenantNote = "UniqueTenantOwnedNote1611";
259
+ const created = await probeExecutor().create(
260
+ { ownerId: crypto.randomUUID(), userNote: "irrelevant-user-note", tenantNote },
261
+ admin,
262
+ probeTenantDb(),
263
+ );
264
+ if (!created.isSuccess) throw new Error("create failed");
265
+ const id = String(created.data.id);
266
+
267
+ await searchStack.eventDispatcher?.runOnce();
268
+ expect(
269
+ (await searchStack.search.search(admin.tenantId, tenantNote, { filterType: "probe" })).some(
270
+ (h) => String(h.entityId) === id,
271
+ ),
272
+ ).toBe(true);
273
+
274
+ await searchStack.http.writeOk(
275
+ FORGET,
276
+ { subject: { kind: "tenant", tenantId: admin.tenantId }, reason: REASON },
277
+ dpoUser,
278
+ );
279
+
280
+ const after = await searchStack.search.search(admin.tenantId, tenantNote, {
281
+ filterType: "probe",
282
+ });
283
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
284
+ });
285
+ });
@@ -225,6 +225,11 @@ describe("delivery attempt log under KMS", () => {
225
225
  const otherSubjectBefore = beforeErase.rows.find(
226
226
  (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
227
227
  )?.["recipientAddress"];
228
+ // Guard against a vacuous pass: if the row fell outside the `limit 100`
229
+ // window, both sides below would be `undefined` and the "unaffected"
230
+ // assertion would prove nothing.
231
+ expect(otherSubjectBefore).toBeDefined();
232
+ expect(otherSubjectBefore).not.toBe(PII_ERASED_SENTINEL);
228
233
 
229
234
  await kms.eraseKey({ kind: "user", userId: subjectId });
230
235
 
@@ -69,6 +69,23 @@ describe("scenario 1: create + me", () => {
69
69
  expect(me["locale"] == null).toBe(true);
70
70
  });
71
71
 
72
+ test("locale sent by the client is persisted and returned via me", async () => {
73
+ const created = await stack.http.writeOk<{ id: number }>(
74
+ UserHandlers.create,
75
+ {
76
+ email: "locale@example.com",
77
+ displayName: "Locale User",
78
+ passwordHash: "seeded-hash",
79
+ locale: "de",
80
+ },
81
+ systemAdmin,
82
+ );
83
+
84
+ const signedIn = createTestUser({ id: created.id, roles: ["User"] });
85
+ const me = await stack.http.queryOk<Record<string, unknown>>(UserQueries.me, {}, signedIn);
86
+ expect(me["locale"]).toBe("de");
87
+ });
88
+
72
89
  test("normal user cannot create another user", async () => {
73
90
  const normal = createTestUser({ id: 42, roles: ["User"] });
74
91
  const error = await stack.http.writeErr(
@@ -91,6 +91,37 @@ describe("default-mailers dispatch", () => {
91
91
  expect(cap.sent[1]?.subject).toBe("Acme — Dein Datenexport ist bereit");
92
92
  });
93
93
 
94
+ test("null user.locale (der Normalfall seit fw#1637, kein Entity-Default mehr): mailDefaults.locale entscheidet, sonst en", async () => {
95
+ const cap = capturingTransport();
96
+ const resolve = async () => cap.transport;
97
+
98
+ // userLocale: null + mailDefaults.locale: "de" → German subject.
99
+ await makeDefaultExportReadyEmail(resolve, { locale: "de", appName: "Acme" })({
100
+ userId: "u1",
101
+ userEmail: "u1@example.com",
102
+ userLocale: null,
103
+ tenantId: TENANT_A,
104
+ jobId: "j1",
105
+ downloadUrl: "u",
106
+ expiresAt: "x",
107
+ bytesWritten: null,
108
+ });
109
+ expect(cap.sent[0]?.subject).toBe("Acme — Dein Datenexport ist bereit");
110
+
111
+ // userLocale: null + no mailDefaults.locale → template default "en".
112
+ await makeDefaultExportReadyEmail(resolve, { appName: "Acme" })({
113
+ userId: "u2",
114
+ userEmail: "u2@example.com",
115
+ userLocale: null,
116
+ tenantId: TENANT_A,
117
+ jobId: "j2",
118
+ downloadUrl: "u",
119
+ expiresAt: "x",
120
+ bytesWritten: null,
121
+ });
122
+ expect(cap.sent[1]?.subject).toBe("Acme — Your data export is ready");
123
+ });
124
+
94
125
  test("deletion-executed: sendet ueber den ersten Membership-Tenant", async () => {
95
126
  const cap = capturingTransport();
96
127
  const resolved: string[] = [];