@cosmicdrift/kumiko-bundled-features 0.115.0 → 0.116.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/config/index.ts +1 -1
- package/src/config/table.ts +1 -1
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +145 -8
- package/src/data-retention/feature.ts +3 -3
- package/src/data-retention/run-retention-cleanup.ts +102 -19
- package/src/delivery/index.ts +5 -1
- package/src/delivery/tables.ts +1 -1
- package/src/personal-access-tokens/schema/api-token.ts +6 -1
- package/src/sessions/schema/user-session.ts +2 -0
- package/src/tenant/invitation-table.ts +1 -1
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +392 -0
- package/src/user-data-rights-defaults/feature.ts +58 -1
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +37 -0
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +51 -0
- package/src/user-data-rights-defaults/hooks/feature-mounted.ts +10 -0
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +38 -0
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +56 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +107 -0
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +42 -0
- package/src/user-data-rights-defaults/index.ts +21 -0
|
@@ -30,7 +30,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
|
30
30
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
31
31
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
32
32
|
import { createFilesFeature } from "../../files";
|
|
33
|
-
import { createSessionsFeature } from "../../sessions";
|
|
33
|
+
import { createSessionsFeature, userSessionEntity } from "../../sessions";
|
|
34
34
|
import {
|
|
35
35
|
createUserFeature,
|
|
36
36
|
USER_ANONYMIZED_DISPLAY_NAME,
|
|
@@ -74,6 +74,9 @@ beforeAll(async () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
77
|
+
// sessions ist gemountet → der user-session-Hook der defaults läuft im Forget
|
|
78
|
+
// und braucht die Tabelle (#797).
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
77
80
|
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
78
81
|
// tenant-membership-Tabelle (von tenant-feature) manuell anlegen weil
|
|
79
82
|
// wir ohne tenant-feature im stack arbeiten — minimaler Setup.
|
|
@@ -28,7 +28,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
|
28
28
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
29
29
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
30
30
|
import { createFilesFeature } from "../../files";
|
|
31
|
-
import { createSessionsFeature } from "../../sessions";
|
|
31
|
+
import { createSessionsFeature, userSessionEntity } from "../../sessions";
|
|
32
32
|
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
33
33
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
34
34
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -63,6 +63,7 @@ beforeAll(async () => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
66
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
66
67
|
await asRawClient(stack.db).unsafe(`
|
|
67
68
|
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
68
69
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// userData-Hook Integration-Tests für die Bundled-Entity-Hooks (GDPR-Audit
|
|
2
|
+
// 2026-07): user-session, api-token, in-app-message, tenant-invitation,
|
|
3
|
+
// notification-preference, config-value.
|
|
4
|
+
//
|
|
5
|
+
// Zwei Stacks:
|
|
6
|
+
// FULL — alle Source-Features gemountet; testet Export-Inhalt, Forget-
|
|
7
|
+
// Semantik (hard-delete vs. executor-forget vs. anonymize) und
|
|
8
|
+
// Tenant-Isolation.
|
|
9
|
+
// MINIMAL — nur user/files/udr/defaults; testet das Presence-Gating: jeder
|
|
10
|
+
// Hook muss null/no-op liefern statt gegen fehlende Tabellen zu
|
|
11
|
+
// crashen (der Export-Runner hat kein per-Hook try/catch).
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import {
|
|
16
|
+
setupTestStack,
|
|
17
|
+
type TestStack,
|
|
18
|
+
unsafeCreateEntityTable,
|
|
19
|
+
unsafePushTables,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
|
+
import { createChannelInAppFeature, inAppMessagesTable } from "../../channel-in-app";
|
|
23
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
24
|
+
import { configValueEntity, createConfigFeature } from "../../config";
|
|
25
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
26
|
+
import { createDeliveryFeature, notificationPreferenceEntity } from "../../delivery";
|
|
27
|
+
import { createFilesFeature } from "../../files";
|
|
28
|
+
import {
|
|
29
|
+
apiTokenEntity,
|
|
30
|
+
createPersonalAccessTokensFeature,
|
|
31
|
+
PatQueries,
|
|
32
|
+
type PatScopeConfig,
|
|
33
|
+
} from "../../personal-access-tokens";
|
|
34
|
+
import { createSessionsFeature, userSessionEntity } from "../../sessions";
|
|
35
|
+
import { createTenantFeature, tenantInvitationEntity } from "../../tenant";
|
|
36
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
37
|
+
import { createUserDataRightsFeature } from "../../user-data-rights";
|
|
38
|
+
import { createUserDataRightsDefaultsFeature } from "../feature";
|
|
39
|
+
import {
|
|
40
|
+
apiTokenDeleteHook,
|
|
41
|
+
apiTokenExportHook,
|
|
42
|
+
configValueDeleteHook,
|
|
43
|
+
configValueExportHook,
|
|
44
|
+
inAppMessageDeleteHook,
|
|
45
|
+
inAppMessageExportHook,
|
|
46
|
+
notificationPreferenceDeleteHook,
|
|
47
|
+
notificationPreferenceExportHook,
|
|
48
|
+
tenantInvitationDeleteHook,
|
|
49
|
+
tenantInvitationExportHook,
|
|
50
|
+
userSessionDeleteHook,
|
|
51
|
+
userSessionExportHook,
|
|
52
|
+
} from "../index";
|
|
53
|
+
|
|
54
|
+
const PAT_SCOPES: PatScopeConfig = {
|
|
55
|
+
tokens: { label: "Tokens", read: [PatQueries.mine] },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let full: TestStack;
|
|
59
|
+
let minimal: TestStack;
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
full = await setupTestStack({
|
|
63
|
+
features: [
|
|
64
|
+
createUserFeature(),
|
|
65
|
+
createFilesFeature(),
|
|
66
|
+
createDataRetentionFeature(),
|
|
67
|
+
createComplianceProfilesFeature(),
|
|
68
|
+
createSessionsFeature(),
|
|
69
|
+
createConfigFeature(),
|
|
70
|
+
createTenantFeature(),
|
|
71
|
+
createPersonalAccessTokensFeature({ scopes: PAT_SCOPES }),
|
|
72
|
+
createDeliveryFeature(),
|
|
73
|
+
createChannelInAppFeature(),
|
|
74
|
+
createUserDataRightsFeature(),
|
|
75
|
+
createUserDataRightsDefaultsFeature(),
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
await unsafeCreateEntityTable(full.db, userEntity);
|
|
79
|
+
await unsafeCreateEntityTable(full.db, userSessionEntity);
|
|
80
|
+
await unsafeCreateEntityTable(full.db, apiTokenEntity);
|
|
81
|
+
await unsafeCreateEntityTable(full.db, tenantInvitationEntity);
|
|
82
|
+
await unsafeCreateEntityTable(full.db, notificationPreferenceEntity);
|
|
83
|
+
await unsafeCreateEntityTable(full.db, configValueEntity);
|
|
84
|
+
await unsafePushTables(full.db, { inAppMessagesTable });
|
|
85
|
+
|
|
86
|
+
minimal = await setupTestStack({
|
|
87
|
+
features: [
|
|
88
|
+
createUserFeature(),
|
|
89
|
+
createFilesFeature(),
|
|
90
|
+
createDataRetentionFeature(),
|
|
91
|
+
createComplianceProfilesFeature(),
|
|
92
|
+
createSessionsFeature(),
|
|
93
|
+
createUserDataRightsFeature(),
|
|
94
|
+
createUserDataRightsDefaultsFeature(),
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
await full.cleanup();
|
|
101
|
+
await minimal.cleanup();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
105
|
+
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
106
|
+
|
|
107
|
+
function uuid(suffix: number): string {
|
|
108
|
+
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ctx(userId: string, overrides: Record<string, unknown> = {}) {
|
|
112
|
+
return {
|
|
113
|
+
db: full.db,
|
|
114
|
+
registry: full.registry,
|
|
115
|
+
tenantId: TENANT_A,
|
|
116
|
+
userId,
|
|
117
|
+
...overrides,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function rawSelect(sql: string, params: unknown[]) {
|
|
122
|
+
const result = await asRawClient(full.db).unsafe(sql, params);
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
124
|
+
return ((result as any).rows ?? result) as Array<Record<string, unknown>>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function seedUser(id: string): Promise<void> {
|
|
128
|
+
const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
|
|
129
|
+
await seedRow(full.db, userTable, {
|
|
130
|
+
id,
|
|
131
|
+
tenantId: SYSTEM_TENANT,
|
|
132
|
+
email: `user-${id}@example.com`,
|
|
133
|
+
passwordHash: "hashed-password",
|
|
134
|
+
displayName: `User ${id}`,
|
|
135
|
+
locale: "de",
|
|
136
|
+
emailVerified: true,
|
|
137
|
+
roles: '["Member"]',
|
|
138
|
+
status: USER_STATUS.Active,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("user-session userData-hooks", () => {
|
|
143
|
+
async function seedSession(id: string, userId: string, tenantId: string): Promise<void> {
|
|
144
|
+
await asRawClient(full.db).unsafe(
|
|
145
|
+
`INSERT INTO read_user_sessions (id, user_id, tenant_id, created_at, expires_at, ip, user_agent)
|
|
146
|
+
VALUES ($1, $2, $3, now(), now() + interval '1 day', '203.0.113.7', 'TestAgent/1.0')`,
|
|
147
|
+
[id, userId, tenantId],
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("export liefert ip/userAgent, aber keine Session-Id (jti)", async () => {
|
|
152
|
+
await seedSession(uuid(1), "sess-user-1", TENANT_A);
|
|
153
|
+
|
|
154
|
+
const result = await userSessionExportHook(ctx("sess-user-1"));
|
|
155
|
+
expect(result?.entity).toBe("user-session");
|
|
156
|
+
expect(result?.rows).toHaveLength(1);
|
|
157
|
+
expect(result?.rows[0]?.["ip"]).toBe("203.0.113.7");
|
|
158
|
+
expect(result?.rows[0]?.["userAgent"]).toBe("TestAgent/1.0");
|
|
159
|
+
expect(result?.rows[0]?.["id"]).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("delete entfernt Sessions des Users, Cross-Tenant bleibt", async () => {
|
|
163
|
+
await seedSession(uuid(2), "sess-user-2", TENANT_A);
|
|
164
|
+
await seedSession(uuid(3), "sess-user-2", TENANT_B);
|
|
165
|
+
|
|
166
|
+
await userSessionDeleteHook(ctx("sess-user-2"), "delete");
|
|
167
|
+
|
|
168
|
+
const a = await rawSelect(
|
|
169
|
+
"SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
|
|
170
|
+
["sess-user-2", TENANT_A],
|
|
171
|
+
);
|
|
172
|
+
const b = await rawSelect(
|
|
173
|
+
"SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
|
|
174
|
+
["sess-user-2", TENANT_B],
|
|
175
|
+
);
|
|
176
|
+
expect(a).toHaveLength(0);
|
|
177
|
+
expect(b).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("api-token userData-hooks", () => {
|
|
182
|
+
async function seedToken(id: string, userId: string): Promise<void> {
|
|
183
|
+
await asRawClient(full.db).unsafe(
|
|
184
|
+
`INSERT INTO read_api_tokens (id, user_id, tenant_id, name, token_hash, prefix, scopes, created_at)
|
|
185
|
+
VALUES ($1, $2, $3, 'My MacBook', $4, 'kum_ab12', '["read"]', now())`,
|
|
186
|
+
[id, userId, TENANT_A, `hash-${id}`],
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test("export liefert Token-Metadata ohne tokenHash", async () => {
|
|
191
|
+
await seedToken(uuid(10), "pat-user-1");
|
|
192
|
+
|
|
193
|
+
const result = await apiTokenExportHook(ctx("pat-user-1"));
|
|
194
|
+
expect(result?.entity).toBe("api-token");
|
|
195
|
+
expect(result?.rows).toHaveLength(1);
|
|
196
|
+
expect(result?.rows[0]?.["name"]).toBe("My MacBook");
|
|
197
|
+
expect(result?.rows[0]?.["prefix"]).toBe("kum_ab12");
|
|
198
|
+
expect(result?.rows[0]?.["tokenHash"]).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("delete entfernt Tokens (= Revoke)", async () => {
|
|
202
|
+
await seedToken(uuid(11), "pat-user-2");
|
|
203
|
+
|
|
204
|
+
await apiTokenDeleteHook(ctx("pat-user-2"), "delete");
|
|
205
|
+
|
|
206
|
+
const rows = await rawSelect("SELECT * FROM read_api_tokens WHERE user_id = $1", [
|
|
207
|
+
"pat-user-2",
|
|
208
|
+
]);
|
|
209
|
+
expect(rows).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("in-app-message userData-hooks", () => {
|
|
214
|
+
async function seedMessage(userId: string, title: string): Promise<void> {
|
|
215
|
+
await asRawClient(full.db).unsafe(
|
|
216
|
+
`INSERT INTO in_app_messages (tenant_id, user_id, notification_type, title, body)
|
|
217
|
+
VALUES ($1, $2, 'test:notify', $3, 'Hallo Marc, dein Export ist da')`,
|
|
218
|
+
[TENANT_A, userId, title],
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("export liefert Messages, delete entfernt sie", async () => {
|
|
223
|
+
await seedMessage("inapp-user-1", "Export fertig");
|
|
224
|
+
|
|
225
|
+
const result = await inAppMessageExportHook(ctx("inapp-user-1"));
|
|
226
|
+
expect(result?.entity).toBe("in-app-message");
|
|
227
|
+
expect(result?.rows[0]?.["title"]).toBe("Export fertig");
|
|
228
|
+
|
|
229
|
+
await inAppMessageDeleteHook(ctx("inapp-user-1"), "delete");
|
|
230
|
+
const rows = await rawSelect("SELECT * FROM in_app_messages WHERE user_id = $1", [
|
|
231
|
+
"inapp-user-1",
|
|
232
|
+
]);
|
|
233
|
+
expect(rows).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("tenant-invitation userData-hooks", () => {
|
|
238
|
+
async function seedInvitation(
|
|
239
|
+
id: string,
|
|
240
|
+
email: string,
|
|
241
|
+
invitedBy: string,
|
|
242
|
+
tenantId = TENANT_A,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
await asRawClient(full.db).unsafe(
|
|
245
|
+
`INSERT INTO read_tenant_invitations (id, tenant_id, email, role, status, invited_by, expires_at)
|
|
246
|
+
VALUES ($1, $2, $3, 'Member', 'pending', $4, now() + interval '7 days')`,
|
|
247
|
+
[id, tenantId, email, invitedBy],
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test("export matcht Invitations über die User-Email", async () => {
|
|
252
|
+
await seedUser(uuid(20));
|
|
253
|
+
await seedInvitation(uuid(21), `user-${uuid(20)}@example.com`, "admin-1");
|
|
254
|
+
|
|
255
|
+
const result = await tenantInvitationExportHook(ctx(uuid(20)));
|
|
256
|
+
expect(result?.entity).toBe("tenant-invitation");
|
|
257
|
+
expect(result?.rows).toHaveLength(1);
|
|
258
|
+
expect(result?.rows[0]?.["role"]).toBe("Member");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("forget delete purged die Invitee-Row (rebuild-sicher via Executor)", async () => {
|
|
262
|
+
await seedUser(uuid(22));
|
|
263
|
+
await seedInvitation(uuid(23), `user-${uuid(22)}@example.com`, "admin-1");
|
|
264
|
+
|
|
265
|
+
await tenantInvitationDeleteHook(ctx(uuid(22)), "delete");
|
|
266
|
+
|
|
267
|
+
const rows = await rawSelect("SELECT * FROM read_tenant_invitations WHERE id = $1", [uuid(23)]);
|
|
268
|
+
expect(rows).toHaveLength(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("anonymize pseudonymisiert die Invitee-Email (unique-index-sicher)", async () => {
|
|
272
|
+
await seedUser(uuid(24));
|
|
273
|
+
await seedInvitation(uuid(25), `user-${uuid(24)}@example.com`, "admin-1");
|
|
274
|
+
|
|
275
|
+
await tenantInvitationDeleteHook(ctx(uuid(24)), "anonymize");
|
|
276
|
+
|
|
277
|
+
const rows = await rawSelect("SELECT email FROM read_tenant_invitations WHERE id = $1", [
|
|
278
|
+
uuid(25),
|
|
279
|
+
]);
|
|
280
|
+
expect(rows).toHaveLength(1);
|
|
281
|
+
expect(String(rows[0]?.["email"])).toBe(`forgotten-${uuid(25)}@anonymized.invalid`);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("userEmailBeforeDelete hat Vorrang (User-Row schon anonymisiert)", async () => {
|
|
285
|
+
await seedInvitation(uuid(26), "vanished@example.com", "admin-1");
|
|
286
|
+
|
|
287
|
+
await tenantInvitationDeleteHook(
|
|
288
|
+
ctx("ghost-user-forgotten", { userEmailBeforeDelete: "Vanished@Example.com" }),
|
|
289
|
+
"delete",
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const rows = await rawSelect("SELECT * FROM read_tenant_invitations WHERE id = $1", [uuid(26)]);
|
|
293
|
+
expect(rows).toHaveLength(0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("Inviter-Forget severs invitedBy, Row bleibt (gehört dem Invitee)", async () => {
|
|
297
|
+
// userId als echte UUID — der Email-Lookup geht gegen read_users.id (uuid).
|
|
298
|
+
const inviter = uuid(28);
|
|
299
|
+
await seedInvitation(uuid(27), "someone-else@example.com", inviter);
|
|
300
|
+
|
|
301
|
+
await tenantInvitationDeleteHook(ctx(inviter), "delete");
|
|
302
|
+
|
|
303
|
+
const rows = await rawSelect(
|
|
304
|
+
"SELECT email, invited_by FROM read_tenant_invitations WHERE id = $1",
|
|
305
|
+
[uuid(27)],
|
|
306
|
+
);
|
|
307
|
+
expect(rows).toHaveLength(1);
|
|
308
|
+
expect(rows[0]?.["email"]).toBe("someone-else@example.com");
|
|
309
|
+
expect(rows[0]?.["invited_by"]).toBe("anonymized");
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("notification-preference userData-hooks", () => {
|
|
314
|
+
async function seedPreference(id: string, userId: string): Promise<void> {
|
|
315
|
+
await asRawClient(full.db).unsafe(
|
|
316
|
+
`INSERT INTO read_notification_preferences (id, tenant_id, user_id, notification_type, channel, enabled)
|
|
317
|
+
VALUES ($1, $2, $3, 'test:notify', 'email', false)`,
|
|
318
|
+
[id, TENANT_A, userId],
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
test("export liefert Prefs, forget purged sie via Executor", async () => {
|
|
323
|
+
await seedPreference(uuid(30), "pref-user-1");
|
|
324
|
+
|
|
325
|
+
const result = await notificationPreferenceExportHook(ctx("pref-user-1"));
|
|
326
|
+
expect(result?.entity).toBe("notification-preference");
|
|
327
|
+
expect(result?.rows[0]?.["channel"]).toBe("email");
|
|
328
|
+
expect(result?.rows[0]?.["enabled"]).toBe(false);
|
|
329
|
+
|
|
330
|
+
await notificationPreferenceDeleteHook(ctx("pref-user-1"), "delete");
|
|
331
|
+
const rows = await rawSelect("SELECT * FROM read_notification_preferences WHERE user_id = $1", [
|
|
332
|
+
"pref-user-1",
|
|
333
|
+
]);
|
|
334
|
+
expect(rows).toHaveLength(0);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("config-value userData-hooks", () => {
|
|
339
|
+
async function seedConfig(id: string, key: string, userId: string | null): Promise<void> {
|
|
340
|
+
await asRawClient(full.db).unsafe(
|
|
341
|
+
`INSERT INTO read_config_values (id, tenant_id, key, value, user_id)
|
|
342
|
+
VALUES ($1, $2, $3, '"dark"', $4)`,
|
|
343
|
+
[id, TENANT_A, key, userId],
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
test("export + forget treffen NUR user-scoped Rows, tenant-scope bleibt", async () => {
|
|
348
|
+
await seedConfig(uuid(40), "ui.theme", "cfg-user-1");
|
|
349
|
+
await seedConfig(uuid(41), "tenant.brand", null);
|
|
350
|
+
|
|
351
|
+
const result = await configValueExportHook(ctx("cfg-user-1"));
|
|
352
|
+
expect(result?.rows).toHaveLength(1);
|
|
353
|
+
expect(result?.rows[0]?.["key"]).toBe("ui.theme");
|
|
354
|
+
|
|
355
|
+
await configValueDeleteHook(ctx("cfg-user-1"), "delete");
|
|
356
|
+
const userRows = await rawSelect("SELECT * FROM read_config_values WHERE user_id = $1", [
|
|
357
|
+
"cfg-user-1",
|
|
358
|
+
]);
|
|
359
|
+
const tenantRows = await rawSelect("SELECT * FROM read_config_values WHERE id = $1", [
|
|
360
|
+
uuid(41),
|
|
361
|
+
]);
|
|
362
|
+
expect(userRows).toHaveLength(0);
|
|
363
|
+
expect(tenantRows).toHaveLength(1);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt Crash", () => {
|
|
368
|
+
test("alle gegateten Hooks no-open auf dem Minimal-Stack", async () => {
|
|
369
|
+
const minCtx = {
|
|
370
|
+
db: minimal.db,
|
|
371
|
+
registry: minimal.registry,
|
|
372
|
+
tenantId: TENANT_A,
|
|
373
|
+
userId: "any-user",
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// sessions ist im Minimal-Stack GEMOUNTET aber die Tabelle existiert
|
|
377
|
+
// nicht — der Gate-Check greift auf Feature-Ebene, deshalb hier nur die
|
|
378
|
+
// fünf wirklich ungemounteten Features. (user-session wird im FULL-Stack
|
|
379
|
+
// getestet.)
|
|
380
|
+
expect(await apiTokenExportHook(minCtx)).toBeNull();
|
|
381
|
+
expect(await inAppMessageExportHook(minCtx)).toBeNull();
|
|
382
|
+
expect(await tenantInvitationExportHook(minCtx)).toBeNull();
|
|
383
|
+
expect(await notificationPreferenceExportHook(minCtx)).toBeNull();
|
|
384
|
+
expect(await configValueExportHook(minCtx)).toBeNull();
|
|
385
|
+
|
|
386
|
+
await expect(apiTokenDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
|
|
387
|
+
await expect(inAppMessageDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
|
|
388
|
+
await expect(tenantInvitationDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
|
|
389
|
+
await expect(notificationPreferenceDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
|
|
390
|
+
await expect(configValueDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
@@ -3,8 +3,23 @@ import {
|
|
|
3
3
|
EXT_USER_DATA,
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { apiTokenDeleteHook, apiTokenExportHook } from "./hooks/api-token.userdata-hook";
|
|
7
|
+
import { configValueDeleteHook, configValueExportHook } from "./hooks/config-value.userdata-hook";
|
|
6
8
|
import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
9
|
+
import {
|
|
10
|
+
inAppMessageDeleteHook,
|
|
11
|
+
inAppMessageExportHook,
|
|
12
|
+
} from "./hooks/in-app-message.userdata-hook";
|
|
13
|
+
import {
|
|
14
|
+
notificationPreferenceDeleteHook,
|
|
15
|
+
notificationPreferenceExportHook,
|
|
16
|
+
} from "./hooks/notification-preference.userdata-hook";
|
|
17
|
+
import {
|
|
18
|
+
tenantInvitationDeleteHook,
|
|
19
|
+
tenantInvitationExportHook,
|
|
20
|
+
} from "./hooks/tenant-invitation.userdata-hook";
|
|
7
21
|
import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
22
|
+
import { userSessionDeleteHook, userSessionExportHook } from "./hooks/user-session.userdata-hook";
|
|
8
23
|
|
|
9
24
|
// user-data-rights-defaults — Default-Hooks für die Core-Entities
|
|
10
25
|
// `user` (S2.H1) und `fileRef` (S2.H2).
|
|
@@ -31,7 +46,7 @@ import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
|
31
46
|
export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
32
47
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
33
48
|
r.describe(
|
|
34
|
-
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the
|
|
49
|
+
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
35
50
|
);
|
|
36
51
|
r.uiHints({
|
|
37
52
|
displayLabel: "User Data Rights · Default Hooks",
|
|
@@ -39,6 +54,18 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
|
39
54
|
recommended: false,
|
|
40
55
|
});
|
|
41
56
|
r.requires("user", "files", "user-data-rights");
|
|
57
|
+
// Optional sources: hooks are registered unconditionally but each one
|
|
58
|
+
// no-ops at runtime when its source feature isn't mounted (see
|
|
59
|
+
// hooks/feature-mounted.ts) — the export runner has no per-hook
|
|
60
|
+
// try/catch, so a query against a missing table would kill the job.
|
|
61
|
+
r.optionalRequires(
|
|
62
|
+
"sessions",
|
|
63
|
+
"personal-access-tokens",
|
|
64
|
+
"channel-in-app",
|
|
65
|
+
"tenant",
|
|
66
|
+
"delivery",
|
|
67
|
+
"config",
|
|
68
|
+
);
|
|
42
69
|
|
|
43
70
|
r.useExtension(EXT_USER_DATA, "user", {
|
|
44
71
|
export: userExportHook,
|
|
@@ -49,5 +76,35 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
|
49
76
|
export: fileRefExportHook,
|
|
50
77
|
delete: fileRefDeleteHook,
|
|
51
78
|
});
|
|
79
|
+
|
|
80
|
+
r.useExtension(EXT_USER_DATA, "user-session", {
|
|
81
|
+
export: userSessionExportHook,
|
|
82
|
+
delete: userSessionDeleteHook,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
r.useExtension(EXT_USER_DATA, "api-token", {
|
|
86
|
+
export: apiTokenExportHook,
|
|
87
|
+
delete: apiTokenDeleteHook,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
r.useExtension(EXT_USER_DATA, "in-app-message", {
|
|
91
|
+
export: inAppMessageExportHook,
|
|
92
|
+
delete: inAppMessageDeleteHook,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
r.useExtension(EXT_USER_DATA, "tenant-invitation", {
|
|
96
|
+
export: tenantInvitationExportHook,
|
|
97
|
+
delete: tenantInvitationDeleteHook,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
r.useExtension(EXT_USER_DATA, "notification-preference", {
|
|
101
|
+
export: notificationPreferenceExportHook,
|
|
102
|
+
delete: notificationPreferenceDeleteHook,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
r.useExtension(EXT_USER_DATA, "config-value", {
|
|
106
|
+
export: configValueExportHook,
|
|
107
|
+
delete: configValueDeleteHook,
|
|
108
|
+
});
|
|
52
109
|
});
|
|
53
110
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { apiTokenTable } from "../../personal-access-tokens";
|
|
4
|
+
import { featureMounted } from "./feature-mounted";
|
|
5
|
+
|
|
6
|
+
// userData-Hooks for personal-access-tokens rows. Same shape as user-session:
|
|
7
|
+
// unmanaged direct-write store (no event stream, rebuild-safe DELETE), and a
|
|
8
|
+
// token without its user is worthless — both strategies hard-delete. Deleting
|
|
9
|
+
// the row also revokes the token (the resolver point-reads this table), which
|
|
10
|
+
// is the correct outcome for a forgotten user.
|
|
11
|
+
|
|
12
|
+
export const apiTokenExportHook: UserDataExportHook = async (ctx) => {
|
|
13
|
+
if (!featureMounted(ctx, "personal-access-tokens")) return null;
|
|
14
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, apiTokenTable, {
|
|
15
|
+
userId: ctx.userId,
|
|
16
|
+
tenantId: ctx.tenantId,
|
|
17
|
+
});
|
|
18
|
+
if (rows.length === 0) return null;
|
|
19
|
+
return {
|
|
20
|
+
entity: "api-token",
|
|
21
|
+
// tokenHash stays out of the bundle — secret-derived, not personal data.
|
|
22
|
+
rows: rows.map((r) => ({
|
|
23
|
+
name: r["name"] ?? null,
|
|
24
|
+
prefix: r["prefix"] ?? null,
|
|
25
|
+
scopes: r["scopes"] ?? null,
|
|
26
|
+
createdAt: String(r["createdAt"] ?? ""),
|
|
27
|
+
expiresAt: r["expiresAt"] ? String(r["expiresAt"]) : null,
|
|
28
|
+
revokedAt: r["revokedAt"] ? String(r["revokedAt"]) : null,
|
|
29
|
+
})),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const apiTokenDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
34
|
+
// skip: personal-access-tokens not mounted — its table doesn't exist, nothing to erase.
|
|
35
|
+
if (!featureMounted(ctx, "personal-access-tokens")) return;
|
|
36
|
+
await deleteMany(ctx.db, apiTokenTable, { userId: ctx.userId, tenantId: ctx.tenantId });
|
|
37
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createSystemUser,
|
|
5
|
+
type UserDataDeleteHook,
|
|
6
|
+
type UserDataExportHook,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { configValueEntity, configValuesTable } from "../../config";
|
|
9
|
+
import { featureMounted } from "./feature-mounted";
|
|
10
|
+
|
|
11
|
+
// userData-Hooks for config's USER-scoped rows (userId set). Tenant-/system-
|
|
12
|
+
// scope rows carry no per-user subject and stay untouched. Event-sourced
|
|
13
|
+
// entity → forget goes through the executor (rebuild replays the erasure).
|
|
14
|
+
// A user-scoped setting without its user is meaningless — both strategies
|
|
15
|
+
// purge via the forget verb, which also erases encrypted value blobs.
|
|
16
|
+
|
|
17
|
+
const crud = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
18
|
+
entityName: "config-value",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const configValueExportHook: UserDataExportHook = async (ctx) => {
|
|
22
|
+
if (!featureMounted(ctx, "config")) return null;
|
|
23
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, configValuesTable, {
|
|
24
|
+
tenantId: ctx.tenantId,
|
|
25
|
+
userId: ctx.userId,
|
|
26
|
+
});
|
|
27
|
+
if (rows.length === 0) return null;
|
|
28
|
+
return {
|
|
29
|
+
entity: "config-value",
|
|
30
|
+
// value may be an encrypted blob — exported as stored; decryption is the
|
|
31
|
+
// config resolver's concern and secret-backed values don't belong in the
|
|
32
|
+
// bundle in plaintext.
|
|
33
|
+
rows: rows.map((r) => ({ key: r["key"], value: r["value"] })),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const configValueDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
38
|
+
// skip: config not mounted — its table doesn't exist, nothing to erase.
|
|
39
|
+
if (!featureMounted(ctx, "config")) return;
|
|
40
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
41
|
+
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
42
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, configValuesTable, {
|
|
43
|
+
tenantId: ctx.tenantId,
|
|
44
|
+
userId: ctx.userId,
|
|
45
|
+
});
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
const id = row["id"]; // @cast-boundary db-row
|
|
48
|
+
if (typeof id !== "string") continue;
|
|
49
|
+
await crud.forget({ id }, systemUser, tdb);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// The defaults feature registers hooks for OPTIONAL source features
|
|
4
|
+
// (optionalRequires). When the source isn't mounted its tables don't exist,
|
|
5
|
+
// and the export runner has no try/catch around hooks — a query against a
|
|
6
|
+
// missing table would kill the whole export job. Every gated hook must
|
|
7
|
+
// early-return on this check before touching the source's tables.
|
|
8
|
+
export function featureMounted(ctx: UserDataHookCtx, featureName: string): boolean {
|
|
9
|
+
return ctx.registry.getFeature(featureName) !== undefined;
|
|
10
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { inAppMessagesTable } from "../../channel-in-app";
|
|
4
|
+
import { featureMounted } from "./feature-mounted";
|
|
5
|
+
|
|
6
|
+
// userData-Hooks for channel-in-app's in_app_messages. Plain SQL table (no
|
|
7
|
+
// r.entity, no event stream — see channel-in-app/tables.ts), written by
|
|
8
|
+
// direct INSERT in the channel adapter, so a direct DELETE is rebuild-safe.
|
|
9
|
+
// Messages are addressed TO the user (title/body can quote personal content);
|
|
10
|
+
// both strategies hard-delete.
|
|
11
|
+
//
|
|
12
|
+
// This table has no entity fields, so the V3 pii-annotation boot guard cannot
|
|
13
|
+
// see it — coverage lives solely in these hooks.
|
|
14
|
+
|
|
15
|
+
export const inAppMessageExportHook: UserDataExportHook = async (ctx) => {
|
|
16
|
+
if (!featureMounted(ctx, "channel-in-app")) return null;
|
|
17
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, inAppMessagesTable, {
|
|
18
|
+
userId: ctx.userId,
|
|
19
|
+
tenantId: ctx.tenantId,
|
|
20
|
+
});
|
|
21
|
+
if (rows.length === 0) return null;
|
|
22
|
+
return {
|
|
23
|
+
entity: "in-app-message",
|
|
24
|
+
rows: rows.map((r) => ({
|
|
25
|
+
notificationType: r["notificationType"] ?? null,
|
|
26
|
+
title: r["title"] ?? null,
|
|
27
|
+
body: r["body"] ?? null,
|
|
28
|
+
isRead: r["isRead"] ?? false,
|
|
29
|
+
createdAt: String(r["createdAt"] ?? ""),
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const inAppMessageDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
35
|
+
// skip: channel-in-app not mounted — its table doesn't exist, nothing to erase.
|
|
36
|
+
if (!featureMounted(ctx, "channel-in-app")) return;
|
|
37
|
+
await deleteMany(ctx.db, inAppMessagesTable, { userId: ctx.userId, tenantId: ctx.tenantId });
|
|
38
|
+
};
|