@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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 +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
- package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/field-access.ts +1 -1
- package/src/custom-fields/db/queries/projection.ts +13 -5
- package/src/custom-fields/db/queries/quota.ts +1 -1
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/lib/field-access.ts +4 -0
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +1 -1
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
- package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
- package/src/foundation-shared/config-helpers.ts +7 -3
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
- package/src/tier-engine/feature.ts +8 -2
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
3
|
+
|
|
4
|
+
// Drift-Pin-Tests — diese Werte sind Cross-File-Contracts, ein Wechsel muss
|
|
5
|
+
// bewusst geschehen. Wenn diese Tests rot werden: stop, denk nach, revert.
|
|
6
|
+
// aggregate-id.ts verweist namentlich auf diese Datei.
|
|
7
|
+
|
|
8
|
+
describe("custom-fields drift pins", () => {
|
|
9
|
+
test("fieldDefinition aggregate-id namespace is stable across boots", () => {
|
|
10
|
+
// FIELD_DEFINITION_NAMESPACE is in stone — changing it re-keys every
|
|
11
|
+
// existing fieldDefinition-stream and breaks event-replay +
|
|
12
|
+
// definition-history. If this fails: revert the namespace, do not adjust
|
|
13
|
+
// the expected values.
|
|
14
|
+
const sys = fieldDefinitionAggregateId(
|
|
15
|
+
"00000000-0000-0000-0000-000000000001",
|
|
16
|
+
"customer",
|
|
17
|
+
"internalNumber",
|
|
18
|
+
);
|
|
19
|
+
const sysAgain = fieldDefinitionAggregateId(
|
|
20
|
+
"00000000-0000-0000-0000-000000000001",
|
|
21
|
+
"customer",
|
|
22
|
+
"internalNumber",
|
|
23
|
+
);
|
|
24
|
+
const otherTenant = fieldDefinitionAggregateId(
|
|
25
|
+
"11111111-1111-1111-1111-111111111111",
|
|
26
|
+
"customer",
|
|
27
|
+
"internalNumber",
|
|
28
|
+
);
|
|
29
|
+
const otherKey = fieldDefinitionAggregateId(
|
|
30
|
+
"00000000-0000-0000-0000-000000000001",
|
|
31
|
+
"customer",
|
|
32
|
+
"otherKey",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(sys).toBe(sysAgain); // deterministic: same triple → same id
|
|
36
|
+
expect(sys).not.toBe(otherTenant); // tenantId is part of the key (scope isolation)
|
|
37
|
+
expect(sys).not.toBe(otherKey); // fieldKey is part of the key
|
|
38
|
+
// Pinned actual outputs — the drift-detector for the namespace constant.
|
|
39
|
+
expect(sys).toBe("a6e22096-55ac-54c1-a759-aa42fa94dbe8");
|
|
40
|
+
expect(otherTenant).toBe("5a6cbaf1-159e-53a1-aaed-0e3b836decbe");
|
|
41
|
+
expect(otherKey).toBe("4b683fa3-9560-5747-bee9-46ea237393ac");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
3
|
+
import { defineFieldPayloadSchema } from "../schemas";
|
|
4
|
+
|
|
5
|
+
function parse(input: unknown) {
|
|
6
|
+
const result = defineFieldPayloadSchema.safeParse(input);
|
|
7
|
+
if (!result.success) {
|
|
8
|
+
throw new Error(`payload invalid: ${result.error.message}`);
|
|
9
|
+
}
|
|
10
|
+
return result.data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("buildFieldDefinitionColumns — denormalized columns derive from serializedField", () => {
|
|
14
|
+
test("serializedField.required wins when present (no top-level required)", () => {
|
|
15
|
+
const payload = parse({
|
|
16
|
+
entityName: "customer",
|
|
17
|
+
fieldKey: "internalNumber",
|
|
18
|
+
serializedField: { type: "text", required: true, maxLength: 50 },
|
|
19
|
+
});
|
|
20
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
21
|
+
expect(row.required).toBe(true);
|
|
22
|
+
expect(JSON.parse(row.serializedField).required).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("top-level value is used when serializedField omits the key", () => {
|
|
26
|
+
const payload = parse({
|
|
27
|
+
entityName: "customer",
|
|
28
|
+
fieldKey: "vipFlag",
|
|
29
|
+
serializedField: { type: "boolean" },
|
|
30
|
+
required: true,
|
|
31
|
+
searchable: true,
|
|
32
|
+
displayOrder: 3,
|
|
33
|
+
});
|
|
34
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
35
|
+
expect(row.required).toBe(true);
|
|
36
|
+
expect(row.searchable).toBe(true);
|
|
37
|
+
expect(row.displayOrder).toBe(3);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("serializedField wins over a conflicting top-level value", () => {
|
|
41
|
+
const payload = parse({
|
|
42
|
+
entityName: "customer",
|
|
43
|
+
fieldKey: "code",
|
|
44
|
+
serializedField: { type: "text", required: false },
|
|
45
|
+
required: true,
|
|
46
|
+
});
|
|
47
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
48
|
+
expect(row.required).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("defaults to false/0 when neither source sets the key", () => {
|
|
52
|
+
const payload = parse({
|
|
53
|
+
entityName: "customer",
|
|
54
|
+
fieldKey: "note",
|
|
55
|
+
serializedField: { type: "text" },
|
|
56
|
+
});
|
|
57
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
58
|
+
expect(row.required).toBe(false);
|
|
59
|
+
expect(row.searchable).toBe(false);
|
|
60
|
+
expect(row.displayOrder).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
29
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
30
30
|
import { z } from "zod";
|
|
31
|
+
import { applyRetentionRemovals, selectHostRowsWithCustomFields } from "../db/queries/retention";
|
|
31
32
|
import { fieldDefinitionEntity } from "../entity";
|
|
32
33
|
import { createCustomFieldsFeature } from "../feature";
|
|
33
34
|
import { runCustomFieldsRetention } from "../run-retention";
|
|
@@ -261,4 +262,79 @@ describe("T1.5d: per-field retention sweep", () => {
|
|
|
261
262
|
expect(cf).not.toHaveProperty("temp");
|
|
262
263
|
expect(cf["keepThis"]).toBe("should-stay");
|
|
263
264
|
});
|
|
265
|
+
|
|
266
|
+
test("mixed strategies on one row: delete drops the key, anonymize nulls it, others stay", async () => {
|
|
267
|
+
const propertyId = "66666666-6666-4000-8000-000000000006";
|
|
268
|
+
await defineField("dropMe", {
|
|
269
|
+
type: "text",
|
|
270
|
+
retention: { keepFor: "30d", strategy: "delete" },
|
|
271
|
+
});
|
|
272
|
+
await defineField("nullMe", {
|
|
273
|
+
type: "text",
|
|
274
|
+
retention: { keepFor: "30d", strategy: "anonymize" },
|
|
275
|
+
});
|
|
276
|
+
await defineField("keepMe", { type: "text" });
|
|
277
|
+
await createProperty(propertyId, "MixedStrategies");
|
|
278
|
+
await setField(propertyId, "dropMe", "secret-a");
|
|
279
|
+
await setField(propertyId, "nullMe", "secret-b");
|
|
280
|
+
await setField(propertyId, "keepMe", "public-c");
|
|
281
|
+
await stack.eventDispatcher?.runOnce();
|
|
282
|
+
|
|
283
|
+
await backdateRow(propertyId, "2026-04-22T10:00:00Z");
|
|
284
|
+
|
|
285
|
+
const report = await runCustomFieldsRetention({
|
|
286
|
+
db: stack.db,
|
|
287
|
+
tenantId: admin.tenantId,
|
|
288
|
+
entityName: "property",
|
|
289
|
+
entityTable: propertyTable,
|
|
290
|
+
now: NOW,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(report.removalsByFieldKey).toEqual({ dropMe: 1, nullMe: 1 });
|
|
294
|
+
const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
|
|
295
|
+
expect(cf).not.toHaveProperty("dropMe");
|
|
296
|
+
expect(cf).toHaveProperty("nullMe");
|
|
297
|
+
expect(cf["nullMe"]).toBeNull();
|
|
298
|
+
expect(cf["keepMe"]).toBe("public-c");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("atomic removal touches only the targeted keys — a value written after the scan is not clobbered", async () => {
|
|
302
|
+
const propertyId = "77777777-7777-4000-8000-000000000007";
|
|
303
|
+
await defineField("temp", {
|
|
304
|
+
type: "text",
|
|
305
|
+
retention: { keepFor: "30d", strategy: "delete" },
|
|
306
|
+
});
|
|
307
|
+
// No retention policy → never swept; stands in for a concurrent edit.
|
|
308
|
+
await defineField("liveEdit", { type: "text" });
|
|
309
|
+
await createProperty(propertyId, "Concurrent");
|
|
310
|
+
await setField(propertyId, "temp", "expired-value");
|
|
311
|
+
await stack.eventDispatcher?.runOnce();
|
|
312
|
+
await backdateRow(propertyId, "2026-04-22T10:00:00Z");
|
|
313
|
+
|
|
314
|
+
// The sweep scans the row (snapshot has only `temp`)...
|
|
315
|
+
const snapshot = await selectHostRowsWithCustomFields(
|
|
316
|
+
stack.db,
|
|
317
|
+
"read_t15d_properties",
|
|
318
|
+
admin.tenantId,
|
|
319
|
+
);
|
|
320
|
+
expect(snapshot).toHaveLength(1);
|
|
321
|
+
|
|
322
|
+
// ...then a concurrent set-custom-field adds `liveEdit`...
|
|
323
|
+
await setField(propertyId, "liveEdit", "written-mid-sweep");
|
|
324
|
+
await stack.eventDispatcher?.runOnce();
|
|
325
|
+
|
|
326
|
+
// ...then the sweep applies the removal it computed from the (now stale)
|
|
327
|
+
// snapshot. This drives `applyRetentionRemovals` directly because the
|
|
328
|
+
// scan→write window inside `runCustomFieldsRetention` can't be paused
|
|
329
|
+
// mid-flight in-process. It pins the property that actually removes the
|
|
330
|
+
// lost-update class: the write is `custom_fields - {temp}` against the LIVE
|
|
331
|
+
// row, never a read-modify-write of the whole jsonb — so a key absent from
|
|
332
|
+
// the removal lists survives. The pre-fix code rebuilt the whole object
|
|
333
|
+
// from the stale snapshot and would have dropped `liveEdit`.
|
|
334
|
+
await applyRetentionRemovals(stack.db, "read_t15d_properties", ["temp"], [], propertyId);
|
|
335
|
+
|
|
336
|
+
const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
|
|
337
|
+
expect(cf).not.toHaveProperty("temp");
|
|
338
|
+
expect(cf["liveEdit"]).toBe("written-mid-sweep");
|
|
339
|
+
});
|
|
264
340
|
});
|
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
// T1.5c — user-data-rights wiring for custom-fields
|
|
1
|
+
// T1.5c — user-data-rights wiring for custom-fields, exercised through the
|
|
2
|
+
// REAL export/forget runners (runUserExport / runForgetCleanup), not by
|
|
3
|
+
// calling the registered hooks in isolation.
|
|
2
4
|
//
|
|
3
|
-
//
|
|
4
|
-
// host entity:
|
|
5
|
+
// What the real runners prove that direct hook calls cannot:
|
|
5
6
|
//
|
|
6
|
-
// *
|
|
7
|
-
//
|
|
8
|
-
// `<entity>.customFields`.
|
|
7
|
+
// * runUserExport actually picks up the custom-fields export hook from the
|
|
8
|
+
// registry and folds its snippet into the user's cross-tenant bundle.
|
|
9
9
|
//
|
|
10
|
-
// *
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// * runForgetCleanup fires BOTH the host EXT_USER_DATA hook (owner-nulling
|
|
11
|
+
// anonymize) AND the custom-fields strip hook, in the order their declared
|
|
12
|
+
// `order` demands. The strip declares order -100 so it redacts sensitive
|
|
13
|
+
// jsonb keyed on `inserted_by_id` BEFORE the host hook nulls that column.
|
|
14
|
+
// If the ordering regressed, the host hook would null inserted_by_id first,
|
|
15
|
+
// the strip's `WHERE inserted_by_id = userId` would match 0 rows, and
|
|
16
|
+
// sensitive PII would silently survive (Art. 17 violation). Calling the
|
|
17
|
+
// hooks by hand never exercised this interaction.
|
|
13
18
|
//
|
|
14
|
-
// *
|
|
15
|
-
//
|
|
19
|
+
// * The anonymize-vs-delete strategy comes from the data-retention policy:
|
|
20
|
+
// no override → strategy "delete" (custom-fields strip is a no-op, host
|
|
21
|
+
// deletes the row); per-tenant anonymize override → strategy "anonymize"
|
|
22
|
+
// (strip runs, host nulls the owner).
|
|
16
23
|
|
|
17
24
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
-
import {
|
|
25
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
26
|
+
import {
|
|
27
|
+
buildEntityTable,
|
|
28
|
+
createEventStoreExecutor,
|
|
29
|
+
createTenantDb,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
31
|
import {
|
|
21
32
|
createEntity,
|
|
22
33
|
createEntityExecutor,
|
|
@@ -32,19 +43,28 @@ import {
|
|
|
32
43
|
resetEventStore,
|
|
33
44
|
setupTestStack,
|
|
34
45
|
type TestStack,
|
|
46
|
+
TestUsers,
|
|
35
47
|
unsafeCreateEntityTable,
|
|
36
48
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
49
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
37
50
|
import { z } from "zod";
|
|
38
51
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
39
|
-
import { createDataRetentionFeature } from "../../data-retention";
|
|
52
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
53
|
+
import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
|
|
40
54
|
import { createSessionsFeature } from "../../sessions";
|
|
41
|
-
import { createUserFeature, userEntity } from "../../user";
|
|
55
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
42
56
|
import { createUserDataRightsFeature } from "../../user-data-rights";
|
|
57
|
+
import { runForgetCleanup } from "../../user-data-rights/run-forget-cleanup";
|
|
58
|
+
import { runUserExport } from "../../user-data-rights/run-user-export";
|
|
43
59
|
import { fieldDefinitionEntity } from "../entity";
|
|
44
60
|
import { createCustomFieldsFeature } from "../feature";
|
|
45
61
|
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
46
62
|
import { wireCustomFieldsUserDataRightsFor } from "../wire-user-data-rights";
|
|
47
63
|
|
|
64
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
65
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
66
|
+
const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
67
|
+
|
|
48
68
|
const propertyEntity = createEntity({
|
|
49
69
|
table: "read_t15c_properties",
|
|
50
70
|
fields: {
|
|
@@ -55,15 +75,12 @@ const propertyEntity = createEntity({
|
|
|
55
75
|
const propertyTable = buildEntityTable("property", propertyEntity);
|
|
56
76
|
|
|
57
77
|
// Host entity gets its own EXT_USER_DATA-registration too — that's the
|
|
58
|
-
// canonical setup
|
|
59
|
-
//
|
|
60
|
-
// same
|
|
78
|
+
// canonical setup. The host's anonymize hook NULLS inserted_by_id (default
|
|
79
|
+
// order 0); the custom-fields strip (order -100) must run first. Both fire in
|
|
80
|
+
// the same runForgetCleanup sub-transaction.
|
|
61
81
|
const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
62
82
|
const rows = await asRawClient(ctx.db).unsafe(
|
|
63
|
-
`
|
|
64
|
-
SELECT id, name FROM read_t15c_properties
|
|
65
|
-
WHERE inserted_by_id = $1 AND tenant_id = $2
|
|
66
|
-
`,
|
|
83
|
+
`SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
67
84
|
[ctx.userId, ctx.tenantId],
|
|
68
85
|
);
|
|
69
86
|
const list = rows as ReadonlyArray<Record<string, unknown>>;
|
|
@@ -77,19 +94,15 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
|
77
94
|
const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
78
95
|
if (strategy === "delete") {
|
|
79
96
|
await asRawClient(ctx.db).unsafe(
|
|
80
|
-
`
|
|
81
|
-
DELETE FROM read_t15c_properties
|
|
82
|
-
WHERE inserted_by_id = $1 AND tenant_id = $2
|
|
83
|
-
`,
|
|
97
|
+
`DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
84
98
|
[ctx.userId, ctx.tenantId],
|
|
85
99
|
);
|
|
86
100
|
} else {
|
|
87
|
-
// anonymize: clear owner, keep row + non-sensitive customFields
|
|
101
|
+
// anonymize: clear owner, keep row + non-sensitive customFields. Runs AFTER
|
|
102
|
+
// the custom-fields strip (order -100 < 0) — if it ran first, the strip's
|
|
103
|
+
// owner-keyed WHERE would match nothing.
|
|
88
104
|
await asRawClient(ctx.db).unsafe(
|
|
89
|
-
`
|
|
90
|
-
UPDATE read_t15c_properties SET inserted_by_id = NULL
|
|
91
|
-
WHERE inserted_by_id = $1 AND tenant_id = $2
|
|
92
|
-
`,
|
|
105
|
+
`UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
93
106
|
[ctx.userId, ctx.tenantId],
|
|
94
107
|
);
|
|
95
108
|
}
|
|
@@ -125,8 +138,10 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
|
|
|
125
138
|
|
|
126
139
|
const customFieldsFeature = createCustomFieldsFeature();
|
|
127
140
|
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
|
|
141
|
+
const TENANT = admin.tenantId;
|
|
128
142
|
|
|
129
143
|
let stack: TestStack;
|
|
144
|
+
let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
130
145
|
|
|
131
146
|
beforeAll(async () => {
|
|
132
147
|
stack = await setupTestStack({
|
|
@@ -143,7 +158,34 @@ beforeAll(async () => {
|
|
|
143
158
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
144
159
|
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
145
160
|
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
161
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
146
162
|
await createEventsTable(stack.db);
|
|
163
|
+
|
|
164
|
+
// runForgetCleanup + runUserExport iterate the user's memberships. Provide a
|
|
165
|
+
// minimal membership read-model (same shape the user-data-rights suite uses).
|
|
166
|
+
await asRawClient(stack.db).unsafe(`
|
|
167
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
168
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
169
|
+
tenant_id UUID NOT NULL,
|
|
170
|
+
user_id TEXT NOT NULL,
|
|
171
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
172
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
173
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
174
|
+
inserted_by_id TEXT,
|
|
175
|
+
modified_by_id TEXT,
|
|
176
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
177
|
+
deleted_at TIMESTAMPTZ,
|
|
178
|
+
deleted_by_id TEXT,
|
|
179
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
180
|
+
UNIQUE(user_id, tenant_id)
|
|
181
|
+
)
|
|
182
|
+
`);
|
|
183
|
+
|
|
184
|
+
overrideExecutor = createEventStoreExecutor(
|
|
185
|
+
tenantRetentionOverrideTable,
|
|
186
|
+
tenantRetentionOverrideEntity,
|
|
187
|
+
{ entityName: "tenant-retention-override" },
|
|
188
|
+
);
|
|
147
189
|
});
|
|
148
190
|
|
|
149
191
|
afterAll(async () => {
|
|
@@ -154,6 +196,9 @@ beforeEach(async () => {
|
|
|
154
196
|
await resetEventStore(stack);
|
|
155
197
|
await asRawClient(stack.db).unsafe(`DELETE FROM read_t15c_properties`);
|
|
156
198
|
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
199
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
200
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
|
|
201
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantRetentionOverrideTable.tableName}"`);
|
|
157
202
|
});
|
|
158
203
|
|
|
159
204
|
async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
|
|
@@ -185,43 +230,78 @@ async function setField(entityId: string, fieldKey: string, value: unknown) {
|
|
|
185
230
|
|
|
186
231
|
async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
|
|
187
232
|
const rows = await asRawClient(stack.db).unsafe(
|
|
188
|
-
`SELECT id, custom_fields FROM read_t15c_properties WHERE id = $1`,
|
|
233
|
+
`SELECT id, custom_fields, inserted_by_id FROM read_t15c_properties WHERE id = $1`,
|
|
189
234
|
[id],
|
|
190
235
|
);
|
|
191
236
|
const list = rows as ReadonlyArray<Record<string, unknown>>;
|
|
192
237
|
return list[0];
|
|
193
238
|
}
|
|
194
239
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
240
|
+
// Seed the acting admin as a normal active user with a membership in TENANT so
|
|
241
|
+
// the export runner iterates their data. Status active = NOT picked up by
|
|
242
|
+
// runForgetCleanup.
|
|
243
|
+
async function seedActiveUserWithMembership(): Promise<void> {
|
|
244
|
+
await insertOne(stack.db, userTable, {
|
|
245
|
+
id: admin.id,
|
|
246
|
+
tenantId: TENANT,
|
|
247
|
+
email: `admin@example.com`,
|
|
248
|
+
passwordHash: "hashed",
|
|
249
|
+
displayName: "Admin",
|
|
250
|
+
locale: "de",
|
|
251
|
+
emailVerified: true,
|
|
252
|
+
roles: '["TenantAdmin"]',
|
|
253
|
+
status: USER_STATUS.Active,
|
|
254
|
+
});
|
|
255
|
+
await seedMembership();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Seed the acting admin as DeletionRequested + grace expired so
|
|
259
|
+
// runForgetCleanup picks them up, with a membership in TENANT.
|
|
260
|
+
async function seedForgetUserWithMembership(): Promise<void> {
|
|
261
|
+
await insertOne(stack.db, userTable, {
|
|
262
|
+
id: admin.id,
|
|
263
|
+
tenantId: TENANT,
|
|
264
|
+
email: `admin@example.com`,
|
|
265
|
+
passwordHash: "hashed",
|
|
266
|
+
displayName: "Admin",
|
|
267
|
+
locale: "de",
|
|
268
|
+
emailVerified: true,
|
|
269
|
+
roles: '["TenantAdmin"]',
|
|
270
|
+
status: USER_STATUS.DeletionRequested,
|
|
271
|
+
gracePeriodEnd: PAST(),
|
|
272
|
+
});
|
|
273
|
+
await seedMembership();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function seedMembership(): Promise<void> {
|
|
277
|
+
await asRawClient(stack.db).unsafe(
|
|
278
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
279
|
+
VALUES ($1, $2, '["TenantAdmin"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
280
|
+
[TENANT, admin.id],
|
|
203
281
|
);
|
|
204
|
-
if (!customFieldsUsage) throw new Error("custom-fields user-data-rights export hook not found");
|
|
205
|
-
const hook = (customFieldsUsage.options as { export: UserDataExportHook }).export;
|
|
206
|
-
return hook({ db: stack.db, tenantId, userId });
|
|
207
282
|
}
|
|
208
283
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
(
|
|
284
|
+
// Set a per-tenant retention override for the property entity through the same
|
|
285
|
+
// event-store path the forget resolver reads — no test-only shortcut.
|
|
286
|
+
async function seedPropertyAnonymizeOverride(): Promise<void> {
|
|
287
|
+
const by = { ...TestUsers.systemAdmin, tenantId: TENANT };
|
|
288
|
+
const result = await overrideExecutor.create(
|
|
289
|
+
{
|
|
290
|
+
entityName: "property",
|
|
291
|
+
config: JSON.stringify({ keepFor: "30d", strategy: "anonymize" }),
|
|
292
|
+
reason: "test",
|
|
293
|
+
tenantId: TENANT,
|
|
294
|
+
},
|
|
295
|
+
by,
|
|
296
|
+
createTenantDb(stack.db, TENANT, "system"),
|
|
217
297
|
);
|
|
218
|
-
if (!
|
|
219
|
-
|
|
220
|
-
return hook({ db: stack.db, tenantId, userId }, strategy);
|
|
298
|
+
if (!result.isSuccess)
|
|
299
|
+
throw new Error(`seedPropertyAnonymizeOverride failed: ${JSON.stringify(result)}`);
|
|
221
300
|
}
|
|
222
301
|
|
|
223
|
-
describe("T1.5c: user-data-rights
|
|
224
|
-
test("export: customFields jsonb
|
|
302
|
+
describe("T1.5c: custom-fields user-data-rights through the real runners", () => {
|
|
303
|
+
test("export: customFields jsonb lands in the user's export bundle", async () => {
|
|
304
|
+
await seedActiveUserWithMembership();
|
|
225
305
|
const propertyId = "11111111-1111-4000-8000-000000000001";
|
|
226
306
|
await defineField("email", { type: "text", sensitive: true });
|
|
227
307
|
await defineField("vipFlag", { type: "boolean" });
|
|
@@ -230,17 +310,27 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
|
|
|
230
310
|
await setField(propertyId, "vipFlag", true);
|
|
231
311
|
await stack.eventDispatcher?.runOnce();
|
|
232
312
|
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
313
|
+
const bundle = await runUserExport({
|
|
314
|
+
db: stack.db,
|
|
315
|
+
registry: stack.registry,
|
|
316
|
+
userId: admin.id,
|
|
317
|
+
now: NOW(),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
|
|
321
|
+
expect(tenantSection).toBeDefined();
|
|
322
|
+
const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
|
|
323
|
+
expect(cfSnippet).toBeDefined();
|
|
324
|
+
expect(cfSnippet?.rows).toHaveLength(1);
|
|
325
|
+
expect(cfSnippet?.rows[0]?.["customFields"]).toMatchObject({
|
|
238
326
|
email: "alice@example.com",
|
|
239
327
|
vipFlag: true,
|
|
240
328
|
});
|
|
241
329
|
});
|
|
242
330
|
|
|
243
|
-
test("forget anonymize: sensitive
|
|
331
|
+
test("forget anonymize: strip runs BEFORE host owner-nulling → sensitive key gone, non-sensitive kept", async () => {
|
|
332
|
+
await seedForgetUserWithMembership();
|
|
333
|
+
await seedPropertyAnonymizeOverride();
|
|
244
334
|
const propertyId = "22222222-2222-4000-8000-000000000002";
|
|
245
335
|
await defineField("email", { type: "text", sensitive: true });
|
|
246
336
|
await defineField("vipFlag", { type: "boolean" });
|
|
@@ -249,50 +339,81 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
|
|
|
249
339
|
await setField(propertyId, "vipFlag", true);
|
|
250
340
|
await stack.eventDispatcher?.runOnce();
|
|
251
341
|
|
|
252
|
-
await
|
|
342
|
+
const result = await runForgetCleanup({
|
|
343
|
+
db: stack.db,
|
|
344
|
+
registry: stack.registry,
|
|
345
|
+
now: NOW(),
|
|
346
|
+
});
|
|
347
|
+
expect(result.processedUserIds).toContain(admin.id);
|
|
348
|
+
expect(result.errors).toHaveLength(0);
|
|
253
349
|
|
|
254
350
|
const row = await readRow(propertyId);
|
|
351
|
+
// Host hook ran (owner nulled), and the strip ran BEFORE it (sensitive
|
|
352
|
+
// key removed despite the owner-keyed WHERE — proof of the -100 ordering).
|
|
353
|
+
expect(row?.["inserted_by_id"]).toBeNull();
|
|
255
354
|
const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
|
|
256
355
|
expect(customFields).toBeDefined();
|
|
257
356
|
expect(customFields).not.toHaveProperty("email");
|
|
258
357
|
expect(customFields).toMatchObject({ vipFlag: true });
|
|
259
358
|
});
|
|
260
359
|
|
|
261
|
-
test("forget delete
|
|
360
|
+
test("forget delete (no override → strategy delete): host removes the row, strip is a no-op", async () => {
|
|
361
|
+
await seedForgetUserWithMembership();
|
|
362
|
+
// No retention override → policyToStrategy(null) = "delete".
|
|
262
363
|
const propertyId = "33333333-3333-4000-8000-000000000003";
|
|
263
364
|
await defineField("email", { type: "text", sensitive: true });
|
|
264
365
|
await createProperty(propertyId, "Delete-Me");
|
|
265
366
|
await setField(propertyId, "email", "alice@example.com");
|
|
266
367
|
await stack.eventDispatcher?.runOnce();
|
|
267
368
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
369
|
+
const result = await runForgetCleanup({
|
|
370
|
+
db: stack.db,
|
|
371
|
+
registry: stack.registry,
|
|
372
|
+
now: NOW(),
|
|
373
|
+
});
|
|
374
|
+
expect(result.processedUserIds).toContain(admin.id);
|
|
272
375
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
expect(
|
|
376
|
+
// Host delete-hook removed the row; custom-fields strip stayed out of the
|
|
377
|
+
// way (it returns early on strategy=delete).
|
|
378
|
+
expect(await readRow(propertyId)).toBeUndefined();
|
|
276
379
|
});
|
|
277
380
|
|
|
278
381
|
test("export: rows without customFields are not included in the snippet", async () => {
|
|
382
|
+
await seedActiveUserWithMembership();
|
|
279
383
|
const propertyId = "44444444-4444-4000-8000-000000000004";
|
|
280
384
|
await createProperty(propertyId, "NoCustomFields");
|
|
385
|
+
await stack.eventDispatcher?.runOnce();
|
|
281
386
|
|
|
282
|
-
const
|
|
283
|
-
|
|
387
|
+
const bundle = await runUserExport({
|
|
388
|
+
db: stack.db,
|
|
389
|
+
registry: stack.registry,
|
|
390
|
+
userId: admin.id,
|
|
391
|
+
now: NOW(),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
|
|
395
|
+
const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
|
|
396
|
+
expect(cfSnippet).toBeUndefined();
|
|
284
397
|
});
|
|
285
398
|
|
|
286
|
-
test("anonymize without sensitive fields defined
|
|
399
|
+
test("forget anonymize without sensitive fields defined → all customFields kept", async () => {
|
|
400
|
+
await seedForgetUserWithMembership();
|
|
401
|
+
await seedPropertyAnonymizeOverride();
|
|
287
402
|
const propertyId = "55555555-5555-4000-8000-000000000005";
|
|
288
403
|
await defineField("nonSensitive", { type: "text" });
|
|
289
404
|
await createProperty(propertyId, "AllStay");
|
|
290
405
|
await setField(propertyId, "nonSensitive", "still-here");
|
|
291
406
|
await stack.eventDispatcher?.runOnce();
|
|
292
407
|
|
|
293
|
-
await
|
|
408
|
+
const result = await runForgetCleanup({
|
|
409
|
+
db: stack.db,
|
|
410
|
+
registry: stack.registry,
|
|
411
|
+
now: NOW(),
|
|
412
|
+
});
|
|
413
|
+
expect(result.processedUserIds).toContain(admin.id);
|
|
294
414
|
|
|
295
415
|
const row = await readRow(propertyId);
|
|
416
|
+
expect(row?.["inserted_by_id"]).toBeNull();
|
|
296
417
|
expect((row?.["custom_fields"] as Record<string, unknown>)?.["nonSensitive"]).toBe(
|
|
297
418
|
"still-here",
|
|
298
419
|
);
|
|
@@ -76,6 +76,35 @@ describe("wireCustomFieldsFor", () => {
|
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("postQuery-hook lets base columns win over shadowing custom fieldKeys", async () => {
|
|
80
|
+
const feature = defineFeature("test-property", (r) => {
|
|
81
|
+
r.entity("property", propertyEntity);
|
|
82
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
|
|
86
|
+
const result = await hook?.(
|
|
87
|
+
{
|
|
88
|
+
entityName: "property",
|
|
89
|
+
rows: [
|
|
90
|
+
{
|
|
91
|
+
id: "p1",
|
|
92
|
+
name: "Hofgarten",
|
|
93
|
+
// a malicious/colliding custom fieldKey must not shadow the real column
|
|
94
|
+
customFields: { id: "spoofed", name: "spoofed", internalNumber: "X-42" },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
{} as never,
|
|
100
|
+
);
|
|
101
|
+
expect(result?.rows[0]).toMatchObject({
|
|
102
|
+
id: "p1",
|
|
103
|
+
name: "Hofgarten",
|
|
104
|
+
internalNumber: "X-42",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
79
108
|
test("postQuery-hook handles missing/invalid customFields gracefully", async () => {
|
|
80
109
|
const feature = defineFeature("test-property", (r) => {
|
|
81
110
|
r.entity("property", propertyEntity);
|