@cosmicdrift/kumiko-bundled-features 0.187.0 → 0.189.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.
Files changed (32) hide show
  1. package/package.json +9 -7
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +16 -0
  3. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +1 -1
  4. package/src/custom-fields/__tests__/retention.integration.test.ts +2 -2
  5. package/src/form-draft/__tests__/cleanup.integration.test.ts +254 -0
  6. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +165 -0
  7. package/src/form-draft/__tests__/form-draft.integration.test.ts +232 -0
  8. package/src/form-draft/changes.json +1 -0
  9. package/src/form-draft/constants.ts +31 -0
  10. package/src/form-draft/db/queries/cleanup.ts +50 -0
  11. package/src/form-draft/db/queries/owned-file-refs.ts +26 -0
  12. package/src/form-draft/entity.ts +51 -0
  13. package/src/form-draft/executor.ts +7 -0
  14. package/src/form-draft/feature.ts +42 -0
  15. package/src/form-draft/handlers/cleanup.job.ts +120 -0
  16. package/src/form-draft/handlers/discard.write.ts +55 -0
  17. package/src/form-draft/handlers/get.query.ts +26 -0
  18. package/src/form-draft/handlers/list.query.ts +40 -0
  19. package/src/form-draft/handlers/save.write.ts +60 -0
  20. package/src/form-draft/i18n.ts +3 -0
  21. package/src/form-draft/index.ts +28 -0
  22. package/src/form-draft/lookup.ts +53 -0
  23. package/src/form-draft/release-file-refs.ts +69 -0
  24. package/src/form-draft/schemas.ts +47 -0
  25. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +146 -0
  26. package/src/form-draft-user-data/changes.json +1 -0
  27. package/src/form-draft-user-data/hooks.ts +49 -0
  28. package/src/form-draft-user-data/index.ts +21 -0
  29. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +1 -1
  30. package/src/tenant/handlers/invitations.query.ts +8 -3
  31. package/src/user-data-rights/__tests__/boot-checks.test.ts +14 -0
  32. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +104 -2
@@ -14,14 +14,18 @@
14
14
  // - Orphan-User (0 Memberships): user-Profil-Hook laeuft trotzdem
15
15
  // ueber Pseudo-Tenant.
16
16
 
17
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
18
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
19
19
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
20
  import {
21
+ configureBlindIndexKey,
21
22
  configurePiiSubjectKms,
22
23
  encryptPiiFieldValues,
23
24
  InMemoryKmsAdapter,
25
+ isPiiCiphertext,
24
26
  } from "@cosmicdrift/kumiko-framework/crypto";
27
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
28
+ import { createSystemUser, type UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
25
29
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
26
30
  import {
27
31
  setupTestStack,
@@ -30,17 +34,21 @@ import {
30
34
  unsafePushTables,
31
35
  } from "@cosmicdrift/kumiko-framework/stack";
32
36
  import {
37
+ resetBlindIndexKeyForTests,
33
38
  resetPiiSubjectKmsForTests,
34
39
  resetTestTables,
35
40
  seedRow,
36
41
  } from "@cosmicdrift/kumiko-framework/testing";
37
42
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
38
43
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
44
+ import { configValueEntity, createConfigFeature } from "../../config";
39
45
  import { createDataRetentionFeature } from "../../data-retention";
40
46
  import { createFilesFeature } from "../../files";
41
47
  import { createSessionsFeature, userSessionEntity } from "../../sessions";
48
+ import { createTenantFeature, tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
42
49
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
43
50
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
51
+ import { tenantInvitationExportHook } from "../../user-data-rights-defaults/hooks/tenant-invitation.userdata-hook";
44
52
  import { createUserDataRightsFeature } from "../feature";
45
53
  import { runUserExport } from "../run-user-export";
46
54
 
@@ -67,6 +75,8 @@ beforeAll(async () => {
67
75
  createComplianceProfilesFeature(),
68
76
  authFoundationFeature,
69
77
  createSessionsFeature(),
78
+ createConfigFeature(),
79
+ createTenantFeature(),
70
80
  createUserDataRightsFeature(),
71
81
  createUserDataRightsDefaultsFeature(),
72
82
  ],
@@ -74,6 +84,8 @@ beforeAll(async () => {
74
84
 
75
85
  await unsafeCreateEntityTable(stack.db, userEntity);
76
86
  await unsafeCreateEntityTable(stack.db, userSessionEntity);
87
+ await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
88
+ await unsafeCreateEntityTable(stack.db, configValueEntity);
77
89
  await asRawClient(stack.db).unsafe(`
78
90
  CREATE TABLE IF NOT EXISTS read_tenant_memberships (
79
91
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -101,7 +113,12 @@ afterAll(async () => {
101
113
  });
102
114
 
103
115
  beforeEach(async () => {
104
- await resetTestTables(stack.db, [userTable, "read_tenant_memberships", fileRefsTable]);
116
+ await resetTestTables(stack.db, [
117
+ userTable,
118
+ "read_tenant_memberships",
119
+ fileRefsTable,
120
+ tenantInvitationsTable,
121
+ ]);
105
122
  });
106
123
 
107
124
  const NOW = () => getTemporal().Now.instant();
@@ -365,3 +382,88 @@ describe("runUserExport :: PII-Subject-Ciphertexte (#820)", () => {
365
382
  expect(json).toContain("[encrypted:unavailable]");
366
383
  });
367
384
  });
385
+
386
+ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
387
+ const BIDX_KEY = Buffer.alloc(32, 9).toString("base64");
388
+ const INVITE_EMAIL = "alice.invite@example.com";
389
+
390
+ afterEach(() => {
391
+ resetPiiSubjectKmsForTests();
392
+ resetBlindIndexKeyForTests();
393
+ });
394
+
395
+ test("tenant-invitation export snippet contains plaintext email even though the hook does not decrypt locally", async () => {
396
+ // KMS + bidx MUST be configured before the executor write so the row
397
+ // lands encrypted with a real blind index, matching the KMS-active
398
+ // production shape (order matches anonymous-deletion-kms.integration.test.ts).
399
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
400
+ configureBlindIndexKey(BIDX_KEY);
401
+
402
+ await seedUser(ALICE_ID, { email: INVITE_EMAIL, displayName: "Alice Invite" });
403
+ await seedMembership(ALICE_ID, TENANT_A);
404
+
405
+ // Real executor write path — the same one tenantInvitationExportHook's
406
+ // sibling delete hook and the invite-create handler use — so the row
407
+ // carries genuine ciphertext + a real email_bidx, not a hand-seeded stand-in.
408
+ const invitationExecutor = createEventStoreExecutor(
409
+ tenantInvitationsTable,
410
+ tenantInvitationEntity,
411
+ {
412
+ entityName: "tenant-invitation",
413
+ },
414
+ );
415
+ const systemUser = createSystemUser(TENANT_A);
416
+ const tenantDb = createTenantDb(stack.db, TENANT_A, "system");
417
+ const createResult = await invitationExecutor.create(
418
+ {
419
+ email: INVITE_EMAIL,
420
+ role: "Member",
421
+ status: "pending",
422
+ invitedBy: ALICE_ID,
423
+ expiresAt: NOW().add({ seconds: 7 * 24 * 60 * 60 }),
424
+ },
425
+ systemUser,
426
+ tenantDb,
427
+ );
428
+ expect(createResult.isSuccess).toBe(true);
429
+ const invitationId = (createResult as { data: { id: string } }).data.id;
430
+
431
+ const rawRows = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
432
+ `SELECT email, email_bidx FROM read_tenant_invitations WHERE id = $1`,
433
+ [invitationId],
434
+ );
435
+ expect(isPiiCiphertext(rawRows[0]?.["email"])).toBe(true);
436
+ expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
437
+
438
+ // Pins the hook boundary directly: tenantInvitationExportHook itself must
439
+ // hand back ciphertext, not plaintext — decryption is the central sweep's
440
+ // job (decryptSnippetFields in run-user-export.ts), not the hook's. If the
441
+ // hook ever started decrypting locally, this assertion would catch it even
442
+ // though the runUserExport() assertion below would stay green either way.
443
+ const hookCtx: UserDataHookCtx = {
444
+ db: stack.db,
445
+ registry: stack.registry,
446
+ tenantId: TENANT_A,
447
+ userId: ALICE_ID,
448
+ };
449
+ const rawSnippet = await tenantInvitationExportHook(hookCtx);
450
+ expect(isPiiCiphertext(rawSnippet?.rows[0]?.["email"])).toBe(true);
451
+
452
+ const bundle = await runUserExport({
453
+ db: stack.db,
454
+ registry: stack.registry,
455
+ userId: ALICE_ID,
456
+ now: NOW(),
457
+ });
458
+
459
+ const tenantA = bundle.tenants.find((t) => t.tenantId === TENANT_A);
460
+ expect(tenantA).toBeDefined();
461
+ // Critical check: the entity-name-keyed selectMany lookup must actually
462
+ // find the row via the blind-index OR-branch — a silent miss here would
463
+ // mean the hook found nothing and the bidx hypothesis is wrong.
464
+ const invitationSnippet = tenantA?.entities.find((e) => e.entity === "tenant-invitation");
465
+ expect(invitationSnippet).toBeDefined();
466
+ expect(invitationSnippet?.rows).toHaveLength(1);
467
+ expect(invitationSnippet?.rows[0]?.["email"]).toBe(INVITE_EMAIL);
468
+ });
469
+ });