@cosmicdrift/kumiko-bundled-features 0.111.0 → 0.112.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 +6 -6
- package/src/folders/__tests__/folders.integration.test.ts +3 -0
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +1 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +108 -0
- package/src/user-data-rights/run-forget-cleanup.ts +1 -0
- package/src/user-data-rights/run-user-export.ts +2 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +31 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.112.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>",
|
|
@@ -95,11 +95,11 @@
|
|
|
95
95
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
98
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.112.0",
|
|
99
|
+
"@cosmicdrift/kumiko-framework": "0.112.0",
|
|
100
|
+
"@cosmicdrift/kumiko-headless": "0.112.0",
|
|
101
|
+
"@cosmicdrift/kumiko-renderer": "0.112.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer-web": "0.112.0",
|
|
103
103
|
"@mollie/api-client": "^4.5.0",
|
|
104
104
|
"@node-rs/argon2": "^2.0.2",
|
|
105
105
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -331,6 +331,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
331
331
|
await seedOneFolderWithAssignment();
|
|
332
332
|
const ctx = {
|
|
333
333
|
db: stack.db,
|
|
334
|
+
registry: stack.registry,
|
|
334
335
|
tenantId: admin.tenantId,
|
|
335
336
|
userId: admin.id,
|
|
336
337
|
tenantModel: "multi-user" as const,
|
|
@@ -345,6 +346,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
345
346
|
await seedOneFolderWithAssignment();
|
|
346
347
|
const ctx = {
|
|
347
348
|
db: stack.db,
|
|
349
|
+
registry: stack.registry,
|
|
348
350
|
tenantId: admin.tenantId,
|
|
349
351
|
userId: admin.id,
|
|
350
352
|
tenantModel: "single-user" as const,
|
|
@@ -359,6 +361,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
359
361
|
await seedOneFolderWithAssignment();
|
|
360
362
|
const ctx = {
|
|
361
363
|
db: stack.db,
|
|
364
|
+
registry: stack.registry,
|
|
362
365
|
tenantId: admin.tenantId,
|
|
363
366
|
userId: admin.id,
|
|
364
367
|
tenantModel: "single-user" as const,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// A forget delete-hook receives the app registry so it can cascade custom child
|
|
2
|
+
// projections for the executor's `<entity>.forgotten` event (runProjectionsForEvent).
|
|
3
|
+
// executor.forget purges only the entity's OWN projection, and the forget pipeline
|
|
4
|
+
// is a job — not a dispatched command — so the dispatcher's post-command projection
|
|
5
|
+
// pass never fires. Without registry in the hook ctx the cascade is unreachable and
|
|
6
|
+
// child read-model rows (m:n joins, per-parent detail projections) are orphaned on
|
|
7
|
+
// live forget (a DSGVO gap). This pins that the plumbing delivers the real,
|
|
8
|
+
// functional registry instance; the end-to-end cascade is covered consumer-side.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
|
|
11
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import {
|
|
13
|
+
createEntity,
|
|
14
|
+
createTextField,
|
|
15
|
+
defineFeature,
|
|
16
|
+
EXT_USER_DATA,
|
|
17
|
+
type Registry,
|
|
18
|
+
type UserDataDeleteHook,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
unsafeCreateEntityTable,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
27
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
28
|
+
import { createSessionsFeature } from "../../sessions";
|
|
29
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
30
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
31
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
32
|
+
import {
|
|
33
|
+
createForgetSeeders,
|
|
34
|
+
nowInstant,
|
|
35
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
36
|
+
TENANT_SYSTEM,
|
|
37
|
+
} from "./forget-test-helpers";
|
|
38
|
+
|
|
39
|
+
const PROBE = "forget-registry-probe";
|
|
40
|
+
let capturedRegistry: Registry | undefined;
|
|
41
|
+
|
|
42
|
+
const captureHook: UserDataDeleteHook = async (ctx) => {
|
|
43
|
+
capturedRegistry = ctx.registry;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const probeEntity = createEntity({
|
|
47
|
+
table: `read_${PROBE.replace(/-/g, "_")}`,
|
|
48
|
+
fields: { name: createTextField({ required: true }) },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const probeFeature = defineFeature(PROBE, (r) => {
|
|
52
|
+
r.entity(PROBE, probeEntity);
|
|
53
|
+
r.useExtension(EXT_USER_DATA, PROBE, {
|
|
54
|
+
export: async () => null,
|
|
55
|
+
delete: captureHook,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000b1";
|
|
60
|
+
let stack: TestStack;
|
|
61
|
+
const seed = (db: unknown) =>
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy writer; this seeder never writes binaries.
|
|
63
|
+
createForgetSeeders(db as any, { write: async () => {} });
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
stack = await setupTestStack({
|
|
67
|
+
features: [
|
|
68
|
+
createUserFeature(),
|
|
69
|
+
createSessionsFeature(),
|
|
70
|
+
createDataRetentionFeature(),
|
|
71
|
+
createComplianceProfilesFeature(),
|
|
72
|
+
createUserDataRightsFeature(),
|
|
73
|
+
probeFeature,
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
78
|
+
await unsafeCreateEntityTable(stack.db, probeEntity);
|
|
79
|
+
await createEventsTable(stack.db);
|
|
80
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await stack.cleanup();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
capturedRegistry = undefined;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("forget delete-hook receives the app registry instance", async () => {
|
|
92
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
93
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT_SYSTEM);
|
|
94
|
+
|
|
95
|
+
const result = await runForgetCleanup({
|
|
96
|
+
db: stack.db,
|
|
97
|
+
registry: stack.registry,
|
|
98
|
+
now: nowInstant(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.errors, JSON.stringify(result.errors)).toHaveLength(0);
|
|
102
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
103
|
+
// The hook ran and got the exact registry passed to runForgetCleanup — not
|
|
104
|
+
// undefined, not a stand-in — so runProjectionsForEvent(event, ctx.registry, db)
|
|
105
|
+
// reaches the real projections.
|
|
106
|
+
expect(capturedRegistry).toBe(stack.registry);
|
|
107
|
+
expect(capturedRegistry?.getProjectionsForSource(PROBE)).toBeDefined();
|
|
108
|
+
});
|
|
@@ -142,7 +142,7 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
142
142
|
for (const tenantId of tenantList) {
|
|
143
143
|
const entities: UserDataExportSnippet[] = [];
|
|
144
144
|
for (const entry of hookEntries) {
|
|
145
|
-
const rawSnippet = await entry.exportHook({ db, tenantId, userId });
|
|
145
|
+
const rawSnippet = await entry.exportHook({ db, registry, tenantId, userId });
|
|
146
146
|
if (rawSnippet === null) continue;
|
|
147
147
|
const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
|
|
148
148
|
entities.push(snippet);
|
|
@@ -177,6 +177,7 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
177
177
|
for (const entry of hookEntries) {
|
|
178
178
|
const rawSnippet = await entry.exportHook({
|
|
179
179
|
db,
|
|
180
|
+
registry,
|
|
180
181
|
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
181
182
|
userId,
|
|
182
183
|
});
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -162,6 +162,7 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
162
162
|
|
|
163
163
|
const result = await userExportHook({
|
|
164
164
|
db: stack.db,
|
|
165
|
+
registry: stack.registry,
|
|
165
166
|
tenantId: TENANT_A,
|
|
166
167
|
userId: uuid(1001),
|
|
167
168
|
});
|
|
@@ -182,6 +183,7 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
182
183
|
test("returns null wenn User nicht existiert", async () => {
|
|
183
184
|
const result = await userExportHook({
|
|
184
185
|
db: stack.db,
|
|
186
|
+
registry: stack.registry,
|
|
185
187
|
tenantId: TENANT_A,
|
|
186
188
|
userId: uuid(1002),
|
|
187
189
|
});
|
|
@@ -193,7 +195,10 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
193
195
|
test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
|
|
194
196
|
await seedUser(uuid(1003));
|
|
195
197
|
|
|
196
|
-
await userDeleteHook(
|
|
198
|
+
await userDeleteHook(
|
|
199
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1003) },
|
|
200
|
+
"delete",
|
|
201
|
+
);
|
|
197
202
|
|
|
198
203
|
const row = await fetchUser(uuid(1003));
|
|
199
204
|
expect(row).not.toBeNull();
|
|
@@ -209,7 +214,10 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
209
214
|
test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
|
|
210
215
|
await seedUser(uuid(1004));
|
|
211
216
|
|
|
212
|
-
await userDeleteHook(
|
|
217
|
+
await userDeleteHook(
|
|
218
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1004) },
|
|
219
|
+
"anonymize",
|
|
220
|
+
);
|
|
213
221
|
|
|
214
222
|
const row = await fetchUser(uuid(1004));
|
|
215
223
|
if (!row) throw new Error("row should exist");
|
|
@@ -222,13 +230,19 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
222
230
|
test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
|
|
223
231
|
await seedUser(uuid(1005));
|
|
224
232
|
|
|
225
|
-
await userDeleteHook(
|
|
233
|
+
await userDeleteHook(
|
|
234
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
|
|
235
|
+
"delete",
|
|
236
|
+
);
|
|
226
237
|
const afterFirst = await fetchUser(uuid(1005));
|
|
227
238
|
if (!afterFirst) throw new Error("user should exist after first delete");
|
|
228
239
|
|
|
229
240
|
// Zweiter Call: kein Crash + State unverändert
|
|
230
241
|
await expect(
|
|
231
|
-
userDeleteHook(
|
|
242
|
+
userDeleteHook(
|
|
243
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
|
|
244
|
+
"delete",
|
|
245
|
+
),
|
|
232
246
|
).resolves.toBeUndefined();
|
|
233
247
|
|
|
234
248
|
// State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
|
|
@@ -249,6 +263,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
249
263
|
|
|
250
264
|
const result = await fileRefExportHook({
|
|
251
265
|
db: stack.db,
|
|
266
|
+
registry: stack.registry,
|
|
252
267
|
tenantId: TENANT_A,
|
|
253
268
|
userId: "user-files-1",
|
|
254
269
|
});
|
|
@@ -263,6 +278,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
263
278
|
test("returns null wenn User keine Files hat", async () => {
|
|
264
279
|
const result = await fileRefExportHook({
|
|
265
280
|
db: stack.db,
|
|
281
|
+
registry: stack.registry,
|
|
266
282
|
tenantId: TENANT_A,
|
|
267
283
|
userId: "ghost-user-no-files",
|
|
268
284
|
});
|
|
@@ -276,7 +292,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
276
292
|
await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
|
|
277
293
|
|
|
278
294
|
await fileRefDeleteHook(
|
|
279
|
-
{ db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
|
|
295
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-delete-files" },
|
|
280
296
|
"delete",
|
|
281
297
|
);
|
|
282
298
|
|
|
@@ -288,7 +304,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
288
304
|
await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
|
|
289
305
|
|
|
290
306
|
await fileRefDeleteHook(
|
|
291
|
-
{ db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
|
|
307
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-anon-files" },
|
|
292
308
|
"anonymize",
|
|
293
309
|
);
|
|
294
310
|
|
|
@@ -305,7 +321,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
305
321
|
await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
|
|
306
322
|
|
|
307
323
|
// Tenant A loescht alle Files von "shared-user"
|
|
308
|
-
await fileRefDeleteHook(
|
|
324
|
+
await fileRefDeleteHook(
|
|
325
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "shared-user" },
|
|
326
|
+
"delete",
|
|
327
|
+
);
|
|
309
328
|
|
|
310
329
|
const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
|
|
311
330
|
const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
|
|
@@ -319,7 +338,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
319
338
|
await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
|
|
320
339
|
|
|
321
340
|
await fileRefDeleteHook(
|
|
322
|
-
{ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
341
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
323
342
|
"delete",
|
|
324
343
|
);
|
|
325
344
|
const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
@@ -327,7 +346,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
327
346
|
|
|
328
347
|
// Zweiter Call: kein Crash + State weiter 0 Files
|
|
329
348
|
await expect(
|
|
330
|
-
fileRefDeleteHook(
|
|
349
|
+
fileRefDeleteHook(
|
|
350
|
+
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
351
|
+
"delete",
|
|
352
|
+
),
|
|
331
353
|
).resolves.toBeUndefined();
|
|
332
354
|
const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
333
355
|
expect(afterSecond).toHaveLength(0);
|