@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.
- package/package.json +9 -7
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +16 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/retention.integration.test.ts +2 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +254 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +165 -0
- package/src/form-draft/__tests__/form-draft.integration.test.ts +232 -0
- package/src/form-draft/changes.json +1 -0
- package/src/form-draft/constants.ts +31 -0
- package/src/form-draft/db/queries/cleanup.ts +50 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +26 -0
- package/src/form-draft/entity.ts +51 -0
- package/src/form-draft/executor.ts +7 -0
- package/src/form-draft/feature.ts +42 -0
- package/src/form-draft/handlers/cleanup.job.ts +120 -0
- package/src/form-draft/handlers/discard.write.ts +55 -0
- package/src/form-draft/handlers/get.query.ts +26 -0
- package/src/form-draft/handlers/list.query.ts +40 -0
- package/src/form-draft/handlers/save.write.ts +60 -0
- package/src/form-draft/i18n.ts +3 -0
- package/src/form-draft/index.ts +28 -0
- package/src/form-draft/lookup.ts +53 -0
- package/src/form-draft/release-file-refs.ts +69 -0
- package/src/form-draft/schemas.ts +47 -0
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +146 -0
- package/src/form-draft-user-data/changes.json +1 -0
- package/src/form-draft-user-data/hooks.ts +49 -0
- package/src/form-draft-user-data/index.ts +21 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +1 -1
- package/src/tenant/handlers/invitations.query.ts +8 -3
- package/src/user-data-rights/__tests__/boot-checks.test.ts +14 -0
- 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, [
|
|
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
|
+
});
|