@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.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__/auth.integration.test.ts +37 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/change-password.write.ts +12 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
- package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
- package/src/compliance-profiles/_internal/parse-override.ts +8 -7
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- 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-access.integration.test.ts +59 -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__/value-schema.test.ts +54 -0
- 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/projection.ts +19 -7
- 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 +8 -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/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +9 -4
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/lib/value-schema.ts +14 -2
- 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 +9 -2
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- 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/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
- 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,211 @@
|
|
|
1
|
+
// Forget-Hook fail-closed Integration-Test.
|
|
2
|
+
//
|
|
3
|
+
// Beweist den Vertrag von runForgetCleanup für den fileRef-delete-Hook: wenn
|
|
4
|
+
// `storageProvider.delete()` fehlschlägt, wirft der Hook → die per-User-Sub-Tx
|
|
5
|
+
// rollt zurück → der User bleibt `DeletionRequested`, die Row + Binary bleiben,
|
|
6
|
+
// der Fehler landet im `errors`-Array. Der nächste Run (Storage wieder ok)
|
|
7
|
+
// konvergiert sauber, weil `delete` idempotent ist. Ohne den Throw würde ein
|
|
8
|
+
// transienter Storage-Fehler die Row permanent hard-löschen, den User auf
|
|
9
|
+
// `Deleted` flippen und die Binary dauerhaft verwaisen lassen (Art.-17-Erasure
|
|
10
|
+
// still unvollständig).
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import {
|
|
16
|
+
createInMemoryFileProvider,
|
|
17
|
+
fileRefsTable,
|
|
18
|
+
type InMemoryFileProvider,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
20
|
+
import {
|
|
21
|
+
setupTestStack,
|
|
22
|
+
type TestStack,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
unsafePushTables,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
28
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
29
|
+
import { createFilesFeature } from "../../files";
|
|
30
|
+
import { createSessionsFeature } from "../../sessions";
|
|
31
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
32
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
33
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
34
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
35
|
+
import {
|
|
36
|
+
createForgetSeeders,
|
|
37
|
+
type ForgetSeeders,
|
|
38
|
+
nowInstant,
|
|
39
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
40
|
+
} from "./forget-test-helpers";
|
|
41
|
+
|
|
42
|
+
let stack: TestStack;
|
|
43
|
+
let db: DbConnection;
|
|
44
|
+
let base: InMemoryFileProvider;
|
|
45
|
+
let seed: ForgetSeeders;
|
|
46
|
+
// `delete` throws while set; flip off to simulate storage recovery on retry.
|
|
47
|
+
let failDeletes = true;
|
|
48
|
+
|
|
49
|
+
const TENANT = "00000000-0000-4000-8000-00000000000e";
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
base = createInMemoryFileProvider();
|
|
53
|
+
// Spread the real provider (all methods bound to its store) and override
|
|
54
|
+
// only `delete` to fail on demand — lets one test prove abort + retry-convergence.
|
|
55
|
+
const flakyProvider: InMemoryFileProvider = {
|
|
56
|
+
...base,
|
|
57
|
+
async delete(key) {
|
|
58
|
+
if (failDeletes) throw new Error("storage unavailable (test)");
|
|
59
|
+
return base.delete(key);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
stack = await setupTestStack({
|
|
63
|
+
features: [
|
|
64
|
+
createUserFeature(),
|
|
65
|
+
createFilesFeature(),
|
|
66
|
+
createDataRetentionFeature(),
|
|
67
|
+
createComplianceProfilesFeature(),
|
|
68
|
+
createSessionsFeature(),
|
|
69
|
+
createUserDataRightsFeature(),
|
|
70
|
+
createUserDataRightsDefaultsFeature({ storageProvider: flakyProvider }),
|
|
71
|
+
],
|
|
72
|
+
files: { storageProvider: flakyProvider },
|
|
73
|
+
});
|
|
74
|
+
db = stack.db;
|
|
75
|
+
// Seeders write binaries through the real store (`base`), not the flaky
|
|
76
|
+
// wrapper — the wrapper's `delete` failure is what the test exercises.
|
|
77
|
+
seed = createForgetSeeders(db, base);
|
|
78
|
+
|
|
79
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
80
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
81
|
+
await unsafePushTables(db, { fileRefsTable });
|
|
82
|
+
await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await stack.cleanup();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
failDeletes = true;
|
|
91
|
+
base.clear();
|
|
92
|
+
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function fileRowCount(tenantId: string, insertedById: string): Promise<number> {
|
|
96
|
+
const rows = await asRawClient(db).unsafe(
|
|
97
|
+
`SELECT 1 FROM file_refs WHERE tenant_id = $1 AND inserted_by_id = $2`,
|
|
98
|
+
[tenantId, insertedById],
|
|
99
|
+
);
|
|
100
|
+
return (rows as ReadonlyArray<unknown>).length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("forget fail-closed :: storage.delete failure aborts the row hard-delete", () => {
|
|
104
|
+
test("storage delete fails → user stays DeletionRequested, row + binary remain, error surfaced", async () => {
|
|
105
|
+
const userId = "cccccccc-cccc-4ccc-8ccc-000000000001";
|
|
106
|
+
await seed.seedForgetUser(userId);
|
|
107
|
+
await seed.seedMembership(userId, TENANT);
|
|
108
|
+
const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000001", TENANT, userId);
|
|
109
|
+
|
|
110
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
|
|
111
|
+
|
|
112
|
+
// NOT flipped to Deleted — the sub-tx rolled back.
|
|
113
|
+
expect(result.processedUserIds).not.toContain(userId);
|
|
114
|
+
// Failure surfaced for operator visibility.
|
|
115
|
+
expect(result.errors.some((e) => e.userId === userId && e.entityName === "fileRef")).toBe(true);
|
|
116
|
+
// Row NOT hard-deleted; binary NOT orphaned.
|
|
117
|
+
expect(await fileRowCount(TENANT, userId)).toBe(1);
|
|
118
|
+
expect(await base.exists(key)).toBe(true);
|
|
119
|
+
// User still pending deletion in the DB.
|
|
120
|
+
const row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
|
|
121
|
+
expect(row?.status).toBe(USER_STATUS.DeletionRequested);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("next run with storage healthy converges (idempotent delete) — user deleted, binary gone", async () => {
|
|
125
|
+
const userId = "cccccccc-cccc-4ccc-8ccc-000000000002";
|
|
126
|
+
await seed.seedForgetUser(userId);
|
|
127
|
+
await seed.seedMembership(userId, TENANT);
|
|
128
|
+
const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000002", TENANT, userId);
|
|
129
|
+
|
|
130
|
+
// First run: storage down → abort.
|
|
131
|
+
const first = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
|
|
132
|
+
expect(first.processedUserIds).not.toContain(userId);
|
|
133
|
+
expect(await base.exists(key)).toBe(true);
|
|
134
|
+
|
|
135
|
+
// Storage recovers; retry converges.
|
|
136
|
+
failDeletes = false;
|
|
137
|
+
const second = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
|
|
138
|
+
|
|
139
|
+
expect(second.processedUserIds).toContain(userId);
|
|
140
|
+
expect(await base.exists(key)).toBe(false);
|
|
141
|
+
expect(await fileRowCount(TENANT, userId)).toBe(0);
|
|
142
|
+
const row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
|
|
143
|
+
expect(row?.status).toBe(USER_STATUS.Deleted);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Same contract, but driven through the REAL dispatcher (POST run-forget-cleanup)
|
|
148
|
+
// rather than calling runForgetCleanup with a top-level connection. The
|
|
149
|
+
// dispatcher wraps the handler in an outer transaction, so ctx.db.raw is a
|
|
150
|
+
// TransactionSql — which has `.savepoint`, not `.begin`. The per-user sub-tx
|
|
151
|
+
// must open as a SAVEPOINT here; the previous `.begin`-only path threw on every
|
|
152
|
+
// user when invoked this way (the cron path), so production deleted nobody while
|
|
153
|
+
// these direct-connection tests stayed green. #214.
|
|
154
|
+
describe("forget-cleanup through the real dispatcher :: per-user savepoint nests under the handler tx", () => {
|
|
155
|
+
const systemUser = {
|
|
156
|
+
id: "00000000-0000-4000-8000-0000000000ff",
|
|
157
|
+
tenantId: TENANT,
|
|
158
|
+
roles: ["SystemAdmin"],
|
|
159
|
+
};
|
|
160
|
+
type CleanupResult = {
|
|
161
|
+
readonly processedUserIds: readonly string[];
|
|
162
|
+
readonly hookCallsAttempted: number;
|
|
163
|
+
readonly errorCount: number;
|
|
164
|
+
readonly errors: ReadonlyArray<{ readonly userId: string; readonly entityName: string }>;
|
|
165
|
+
};
|
|
166
|
+
const RUN_FORGET = "user-data-rights:write:run-forget-cleanup";
|
|
167
|
+
|
|
168
|
+
test("dispatcher POST flips a due user to Deleted (SAVEPOINT inside the outer handler tx)", async () => {
|
|
169
|
+
failDeletes = false;
|
|
170
|
+
const userId = "cccccccc-cccc-4ccc-8ccc-000000000003";
|
|
171
|
+
await seed.seedForgetUser(userId);
|
|
172
|
+
await seed.seedMembership(userId, TENANT);
|
|
173
|
+
const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000003", TENANT, userId);
|
|
174
|
+
|
|
175
|
+
const result = await stack.http.writeOk<CleanupResult>(RUN_FORGET, {}, systemUser);
|
|
176
|
+
|
|
177
|
+
// Pre-fix this list was always empty — `.begin` is absent on the
|
|
178
|
+
// dispatcher's TransactionSql, so the per-user sub-tx threw for every user.
|
|
179
|
+
expect(result.processedUserIds).toContain(userId);
|
|
180
|
+
expect(result.errorCount).toBe(0);
|
|
181
|
+
expect(await base.exists(key)).toBe(false);
|
|
182
|
+
expect(await fileRowCount(TENANT, userId)).toBe(0);
|
|
183
|
+
const row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
|
|
184
|
+
expect(row?.status).toBe(USER_STATUS.Deleted);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("fail-closed + retry-convergence through the dispatcher (savepoint rolls back one user)", async () => {
|
|
188
|
+
const userId = "cccccccc-cccc-4ccc-8ccc-000000000004";
|
|
189
|
+
await seed.seedForgetUser(userId);
|
|
190
|
+
await seed.seedMembership(userId, TENANT);
|
|
191
|
+
const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000004", TENANT, userId);
|
|
192
|
+
|
|
193
|
+
// Storage down → fileRef hook throws → ROLLBACK TO SAVEPOINT undoes just
|
|
194
|
+
// this user; the outer handler tx still commits the run. User stays pending.
|
|
195
|
+
failDeletes = true;
|
|
196
|
+
const failed = await stack.http.writeOk<CleanupResult>(RUN_FORGET, {}, systemUser);
|
|
197
|
+
expect(failed.processedUserIds).not.toContain(userId);
|
|
198
|
+
expect(failed.errors.some((e) => e.userId === userId && e.entityName === "fileRef")).toBe(true);
|
|
199
|
+
expect(await base.exists(key)).toBe(true);
|
|
200
|
+
let row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
|
|
201
|
+
expect(row?.status).toBe(USER_STATUS.DeletionRequested);
|
|
202
|
+
|
|
203
|
+
// Storage recovers → retry converges (idempotent delete).
|
|
204
|
+
failDeletes = false;
|
|
205
|
+
const ok = await stack.http.writeOk<CleanupResult>(RUN_FORGET, {}, systemUser);
|
|
206
|
+
expect(ok.processedUserIds).toContain(userId);
|
|
207
|
+
expect(await base.exists(key)).toBe(false);
|
|
208
|
+
row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
|
|
209
|
+
expect(row?.status).toBe(USER_STATUS.Deleted);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// DSGVO forget hook-ordering — the custom-fields anonymize strip must run
|
|
2
|
+
// before any host hook that nulls the owner column it filters on, regardless
|
|
3
|
+
// of feature registration order.
|
|
4
|
+
//
|
|
5
|
+
// Two host entities are wired with the OPPOSITE EXT_USER_DATA registration
|
|
6
|
+
// order: one registers the custom-fields strip first (the order that happened
|
|
7
|
+
// to be safe), the other registers the owner-nulling host hook first (the
|
|
8
|
+
// order that exposed the bug). Both run through the real `runForgetCleanup`
|
|
9
|
+
// with strategy=anonymize. With the order-sentinel fix both strip the
|
|
10
|
+
// sensitive jsonb key; without it the host-first entity would leave it behind
|
|
11
|
+
// (the strip matches 0 rows after the owner column is nulled).
|
|
12
|
+
//
|
|
13
|
+
// This drives the full runner on purpose: the existing custom-fields
|
|
14
|
+
// user-data-rights test invokes the strip hook in isolation
|
|
15
|
+
// (getExtensionUsages + direct call) and is structurally blind to ordering.
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
20
|
+
import {
|
|
21
|
+
createEntity,
|
|
22
|
+
createTextField,
|
|
23
|
+
defineFeature,
|
|
24
|
+
EXT_USER_DATA,
|
|
25
|
+
type UserDataDeleteHook,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
28
|
+
import {
|
|
29
|
+
createTestUser,
|
|
30
|
+
resetEventStore,
|
|
31
|
+
setupTestStack,
|
|
32
|
+
type TestStack,
|
|
33
|
+
unsafeCreateEntityTable,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
35
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
|
+
import { fieldDefinitionEntity } from "../../custom-fields/entity";
|
|
37
|
+
import { createCustomFieldsFeature } from "../../custom-fields/feature";
|
|
38
|
+
import { customFieldsField } from "../../custom-fields/wire-for-entity";
|
|
39
|
+
import { wireCustomFieldsUserDataRightsFor } from "../../custom-fields/wire-user-data-rights";
|
|
40
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
41
|
+
import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
|
|
42
|
+
import { createSessionsFeature } from "../../sessions";
|
|
43
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
44
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
45
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
46
|
+
import {
|
|
47
|
+
createForgetSeeders,
|
|
48
|
+
nowInstant,
|
|
49
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
50
|
+
} from "./forget-test-helpers";
|
|
51
|
+
|
|
52
|
+
const TENANT = "00000000-0000-4000-8000-0000000000aa";
|
|
53
|
+
|
|
54
|
+
// Owner-nulling host anonymize hook (the canonical "anonymize keeps the row,
|
|
55
|
+
// clears the owner" pattern — same shape as file-ref/user host hooks).
|
|
56
|
+
function makeHostDeleteHook(tableName: string): UserDataDeleteHook {
|
|
57
|
+
return async (ctx, strategy) => {
|
|
58
|
+
if (strategy === "delete") {
|
|
59
|
+
await asRawClient(ctx.db).unsafe(
|
|
60
|
+
`DELETE FROM ${tableName} WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
61
|
+
[ctx.userId, ctx.tenantId],
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await asRawClient(ctx.db).unsafe(
|
|
66
|
+
`UPDATE ${tableName} SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
67
|
+
[ctx.userId, ctx.tenantId],
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface HostSpec {
|
|
73
|
+
readonly entityName: string;
|
|
74
|
+
readonly tableName: string;
|
|
75
|
+
readonly featureName: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CF_FIRST: HostSpec = {
|
|
79
|
+
entityName: "cf-first-prop",
|
|
80
|
+
tableName: "read_dsgvo_cf_first",
|
|
81
|
+
featureName: "dsgvo-cf-first",
|
|
82
|
+
};
|
|
83
|
+
const HOST_FIRST: HostSpec = {
|
|
84
|
+
entityName: "host-first-prop",
|
|
85
|
+
tableName: "read_dsgvo_host_first",
|
|
86
|
+
featureName: "dsgvo-host-first",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function makeEntity(tableName: string) {
|
|
90
|
+
return createEntity({
|
|
91
|
+
table: tableName,
|
|
92
|
+
fields: {
|
|
93
|
+
name: createTextField({ required: true }),
|
|
94
|
+
customFields: customFieldsField(),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cfFirstEntity = makeEntity(CF_FIRST.tableName);
|
|
100
|
+
const hostFirstEntity = makeEntity(HOST_FIRST.tableName);
|
|
101
|
+
const cfFirstTable = buildEntityTable(CF_FIRST.entityName, cfFirstEntity);
|
|
102
|
+
const hostFirstTable = buildEntityTable(HOST_FIRST.entityName, hostFirstEntity);
|
|
103
|
+
|
|
104
|
+
// Registration order is the whole point: cfFirst registers the strip BEFORE the
|
|
105
|
+
// owner-nulling host hook; hostFirst registers the host hook FIRST (the order
|
|
106
|
+
// that exposed the bug). The order-sentinel must make both correct.
|
|
107
|
+
const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
|
|
108
|
+
r.entity(CF_FIRST.entityName, cfFirstEntity);
|
|
109
|
+
r.requires("custom-fields");
|
|
110
|
+
wireCustomFieldsUserDataRightsFor(r, {
|
|
111
|
+
entityName: CF_FIRST.entityName,
|
|
112
|
+
entityTable: cfFirstTable,
|
|
113
|
+
userIdColumn: "inserted_by_id",
|
|
114
|
+
});
|
|
115
|
+
r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
|
|
116
|
+
export: async () => null,
|
|
117
|
+
delete: makeHostDeleteHook(CF_FIRST.tableName),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const hostFirstFeature = defineFeature(HOST_FIRST.featureName, (r) => {
|
|
122
|
+
r.entity(HOST_FIRST.entityName, hostFirstEntity);
|
|
123
|
+
r.requires("custom-fields");
|
|
124
|
+
r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
|
|
125
|
+
export: async () => null,
|
|
126
|
+
delete: makeHostDeleteHook(HOST_FIRST.tableName),
|
|
127
|
+
});
|
|
128
|
+
wireCustomFieldsUserDataRightsFor(r, {
|
|
129
|
+
entityName: HOST_FIRST.entityName,
|
|
130
|
+
entityTable: hostFirstTable,
|
|
131
|
+
userIdColumn: "inserted_by_id",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"], tenantId: TENANT });
|
|
136
|
+
const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000a1";
|
|
137
|
+
|
|
138
|
+
let stack: TestStack;
|
|
139
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
|
|
140
|
+
const seed = (db: unknown) => createForgetSeeders(db as any, { write: async () => {} });
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
stack = await setupTestStack({
|
|
144
|
+
features: [
|
|
145
|
+
createUserFeature(),
|
|
146
|
+
createSessionsFeature(),
|
|
147
|
+
createDataRetentionFeature(),
|
|
148
|
+
createComplianceProfilesFeature(),
|
|
149
|
+
createCustomFieldsFeature(),
|
|
150
|
+
createUserDataRightsFeature(),
|
|
151
|
+
cfFirstFeature,
|
|
152
|
+
hostFirstFeature,
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
156
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
157
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
158
|
+
await unsafeCreateEntityTable(stack.db, cfFirstEntity);
|
|
159
|
+
await unsafeCreateEntityTable(stack.db, hostFirstEntity);
|
|
160
|
+
await createEventsTable(stack.db);
|
|
161
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterAll(async () => {
|
|
165
|
+
await stack.cleanup();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
async function defineSensitiveField(entityName: string, fieldKey: string, sensitive: boolean) {
|
|
169
|
+
await stack.http.writeOk(
|
|
170
|
+
"custom-fields:write:define-tenant-field",
|
|
171
|
+
{
|
|
172
|
+
entityName,
|
|
173
|
+
fieldKey,
|
|
174
|
+
serializedField: { type: "text", sensitive },
|
|
175
|
+
required: false,
|
|
176
|
+
searchable: false,
|
|
177
|
+
displayOrder: 0,
|
|
178
|
+
},
|
|
179
|
+
admin,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function seedAnonymizeOverride(entityName: string) {
|
|
184
|
+
await insertOne(stack.db, tenantRetentionOverrideTable, {
|
|
185
|
+
entityName,
|
|
186
|
+
config: JSON.stringify({ keepFor: "0d", strategy: "anonymize" }),
|
|
187
|
+
reason: "test: force anonymize strategy",
|
|
188
|
+
tenantId: TENANT,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function seedRow(spec: HostSpec, rowId: string) {
|
|
193
|
+
// Inline jsonb literal — binding the JSON as a `$n::jsonb` param double-encodes
|
|
194
|
+
// it into a jsonb *string* scalar (jsonb_typeof='string'), which the strip's
|
|
195
|
+
// object-guard skips. A literal stores a real object, matching what the
|
|
196
|
+
// set-custom-field projection writes in production.
|
|
197
|
+
await asRawClient(stack.db).unsafe(
|
|
198
|
+
`INSERT INTO ${spec.tableName} (id, tenant_id, name, inserted_by_id, custom_fields)
|
|
199
|
+
VALUES ($1, $2, $3, $4, '{"ssn":"123-45-6789","safe":"keep-me"}'::jsonb)`,
|
|
200
|
+
[rowId, TENANT, `Row for ${spec.entityName}`, FORGET_USER],
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function readRow(
|
|
205
|
+
spec: HostSpec,
|
|
206
|
+
rowId: string,
|
|
207
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
208
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
209
|
+
`SELECT id, inserted_by_id, custom_fields FROM ${spec.tableName} WHERE id = $1`,
|
|
210
|
+
[rowId],
|
|
211
|
+
);
|
|
212
|
+
return (rows as ReadonlyArray<Record<string, unknown>>)[0];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
beforeEach(async () => {
|
|
216
|
+
await resetEventStore(stack);
|
|
217
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${CF_FIRST.tableName}`);
|
|
218
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${HOST_FIRST.tableName}`);
|
|
219
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
220
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_retention_overrides`);
|
|
221
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("forget hook-ordering :: redaction runs before owner-nulling regardless of registration order", () => {
|
|
225
|
+
test("both registration orders strip the sensitive jsonb key and null the owner column", async () => {
|
|
226
|
+
// Field defs (sensitive ssn, non-sensitive safe) for both entities.
|
|
227
|
+
for (const spec of [CF_FIRST, HOST_FIRST]) {
|
|
228
|
+
await defineSensitiveField(spec.entityName, "ssn", true);
|
|
229
|
+
await defineSensitiveField(spec.entityName, "safe", false);
|
|
230
|
+
await seedAnonymizeOverride(spec.entityName);
|
|
231
|
+
}
|
|
232
|
+
// Project the field definitions. Assert the pass had no failures: a silent
|
|
233
|
+
// projection failure would leave read_custom_field_definitions empty, make
|
|
234
|
+
// loadSensitiveFieldKeys return [], and turn the strip into a no-op —
|
|
235
|
+
// verifying the test exercises the strip for the right reason.
|
|
236
|
+
const pass = await stack.eventDispatcher?.runOnce();
|
|
237
|
+
expect(pass?.failed ?? 0).toBe(0);
|
|
238
|
+
|
|
239
|
+
const cfRowId = "dddddddd-dddd-4ddd-8ddd-000000000001";
|
|
240
|
+
const hostRowId = "dddddddd-dddd-4ddd-8ddd-000000000002";
|
|
241
|
+
await seedRow(CF_FIRST, cfRowId);
|
|
242
|
+
await seedRow(HOST_FIRST, hostRowId);
|
|
243
|
+
|
|
244
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
245
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT);
|
|
246
|
+
|
|
247
|
+
const result = await runForgetCleanup({
|
|
248
|
+
db: stack.db,
|
|
249
|
+
registry: stack.registry,
|
|
250
|
+
now: nowInstant(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(result.errors).toHaveLength(0);
|
|
254
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
255
|
+
|
|
256
|
+
for (const [spec, rowId] of [
|
|
257
|
+
[CF_FIRST, cfRowId],
|
|
258
|
+
[HOST_FIRST, hostRowId],
|
|
259
|
+
] as const) {
|
|
260
|
+
const row = await readRow(spec, rowId);
|
|
261
|
+
// Owner column nulled by the host anonymize hook.
|
|
262
|
+
expect(row?.["inserted_by_id"]).toBeNull();
|
|
263
|
+
const cf = row?.["custom_fields"] as Record<string, unknown>;
|
|
264
|
+
// Sensitive key stripped (the strip ran while the owner column still
|
|
265
|
+
// pointed to the user) — this is the assertion that fails for the
|
|
266
|
+
// host-first entity without the order-sentinel fix.
|
|
267
|
+
expect(cf).not.toHaveProperty("ssn");
|
|
268
|
+
// Non-sensitive key preserved.
|
|
269
|
+
expect(cf["safe"]).toBe("keep-me");
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Shared seeders for the forget-cleanup integration tests
|
|
2
|
+
// (file-binary-forget-cleanup + file-binary-forget-failure). Both drive
|
|
3
|
+
// runForgetCleanup against the same fileRef + membership shape — keeping the
|
|
4
|
+
// DDL and seed logic in one place so a file_refs/user-schema change updates
|
|
5
|
+
// both at once instead of drifting.
|
|
6
|
+
|
|
7
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
10
|
+
import { USER_STATUS, userTable } from "../../user";
|
|
11
|
+
|
|
12
|
+
export const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
13
|
+
|
|
14
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
15
|
+
export const nowInstant = (): Instant => getTemporal().Now.instant();
|
|
16
|
+
export const pastInstant = (): Instant =>
|
|
17
|
+
getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
18
|
+
|
|
19
|
+
export const READ_TENANT_MEMBERSHIPS_DDL = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
21
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
22
|
+
tenant_id UUID NOT NULL,
|
|
23
|
+
user_id TEXT NOT NULL,
|
|
24
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
26
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
27
|
+
inserted_by_id TEXT,
|
|
28
|
+
modified_by_id TEXT,
|
|
29
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
30
|
+
deleted_at TIMESTAMPTZ,
|
|
31
|
+
deleted_by_id TEXT,
|
|
32
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
33
|
+
UNIQUE(user_id, tenant_id)
|
|
34
|
+
)
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
interface FileWriter {
|
|
38
|
+
write(key: string, bytes: Uint8Array, mimeType: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ForgetSeeders {
|
|
42
|
+
seedForgetUser(id: string): Promise<void>;
|
|
43
|
+
seedMembership(userId: string, tenantId: string): Promise<void>;
|
|
44
|
+
seedFile(id: string, tenantId: string, insertedById: string): Promise<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `writer` is taken separately from the feature's storageProvider so the
|
|
48
|
+
// failure test can seed binaries through the real backing store while the
|
|
49
|
+
// feature runs against a delete-failing wrapper.
|
|
50
|
+
export function createForgetSeeders(db: DbConnection, writer: FileWriter): ForgetSeeders {
|
|
51
|
+
return {
|
|
52
|
+
async seedForgetUser(id) {
|
|
53
|
+
await insertOne(db, userTable, {
|
|
54
|
+
id,
|
|
55
|
+
tenantId: TENANT_SYSTEM,
|
|
56
|
+
email: `user-${id}@example.com`,
|
|
57
|
+
passwordHash: "hashed",
|
|
58
|
+
displayName: `User ${id}`,
|
|
59
|
+
locale: "de",
|
|
60
|
+
emailVerified: true,
|
|
61
|
+
roles: '["Member"]',
|
|
62
|
+
status: USER_STATUS.DeletionRequested,
|
|
63
|
+
gracePeriodEnd: pastInstant(),
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async seedMembership(userId, tenantId) {
|
|
68
|
+
await asRawClient(db).unsafe(
|
|
69
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
70
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
71
|
+
[tenantId, userId],
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async seedFile(id, tenantId, insertedById) {
|
|
76
|
+
const storageKey = `storage/${id}`;
|
|
77
|
+
await writer.write(storageKey, new Uint8Array([1, 2, 3, 4]), "application/pdf");
|
|
78
|
+
await asRawClient(db).unsafe(
|
|
79
|
+
`INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
80
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 4, $5) ON CONFLICT (id) DO NOTHING`,
|
|
81
|
+
[id, tenantId, storageKey, `${id}.pdf`, insertedById],
|
|
82
|
+
);
|
|
83
|
+
return storageKey;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { Temporal } from "temporal-polyfill";
|
|
4
5
|
import { exportJobsTable } from "../../schema/export-job";
|
|
5
6
|
|
|
6
7
|
export type ExportJobCleanupCandidate = {
|