@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3
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/CHANGELOG.md +108 -0
- package/package.json +12 -6
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +33 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +334 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// S2.U7 — my-audit-log + invalid-attempt-audit + list-download-attempts.
|
|
2
|
+
|
|
3
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import {
|
|
5
|
+
createTestUser,
|
|
6
|
+
setupTestStack,
|
|
7
|
+
type TestStack,
|
|
8
|
+
testTenantId,
|
|
9
|
+
unsafeCreateEntityTable,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
|
+
import { sql } from "drizzle-orm";
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
createComplianceProfilesFeature,
|
|
15
|
+
tenantComplianceProfileEntity,
|
|
16
|
+
} from "../../compliance-profiles";
|
|
17
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
18
|
+
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
19
|
+
import { createUserFeature } from "../../user/feature";
|
|
20
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
21
|
+
import { downloadAttemptEntity, downloadAttemptsTable } from "../schema/download-attempt";
|
|
22
|
+
|
|
23
|
+
const MY_AUDIT = "user-data-rights:query:my-audit-log";
|
|
24
|
+
const LIST_ATTEMPTS = "user-data-rights:query:list-download-attempts";
|
|
25
|
+
|
|
26
|
+
let stack: TestStack;
|
|
27
|
+
|
|
28
|
+
const tenantA = testTenantId(1);
|
|
29
|
+
const alice = createTestUser({ id: 42, tenantId: tenantA, roles: ["Member"] });
|
|
30
|
+
const bob = createTestUser({ id: 43, tenantId: tenantA, roles: ["Member"] });
|
|
31
|
+
const admin = createTestUser({ id: 1, tenantId: tenantA, roles: ["Admin"] });
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
stack = await setupTestStack({
|
|
35
|
+
features: [
|
|
36
|
+
createUserFeature(),
|
|
37
|
+
createDataRetentionFeature(),
|
|
38
|
+
createComplianceProfilesFeature(),
|
|
39
|
+
createUserDataRightsFeature(),
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
43
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
44
|
+
await unsafeCreateEntityTable(stack.db, downloadAttemptEntity);
|
|
45
|
+
await createEventsTable(stack.db);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await stack.cleanup();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await stack.db.delete(userTable);
|
|
54
|
+
await stack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
|
|
55
|
+
await stack.db.execute(sql`DELETE FROM read_download_attempts`);
|
|
56
|
+
await stack.db.execute(sql`DELETE FROM kumiko_events`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
async function seedUser(u: typeof alice, email: string): Promise<void> {
|
|
60
|
+
await stack.db.insert(userTable).values({
|
|
61
|
+
id: u.id,
|
|
62
|
+
tenantId: u.tenantId,
|
|
63
|
+
email,
|
|
64
|
+
passwordHash: "h",
|
|
65
|
+
displayName: email,
|
|
66
|
+
locale: "de",
|
|
67
|
+
emailVerified: true,
|
|
68
|
+
roles: '["Member"]',
|
|
69
|
+
status: USER_STATUS.Active,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let _eventVersion = 0;
|
|
74
|
+
async function seedEvent(
|
|
75
|
+
createdBy: string,
|
|
76
|
+
tenantId: string,
|
|
77
|
+
type: string,
|
|
78
|
+
payload: object,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
_eventVersion += 1;
|
|
81
|
+
await stack.db.execute(sql`
|
|
82
|
+
INSERT INTO kumiko_events
|
|
83
|
+
(tenant_id, aggregate_type, aggregate_id, version, type, payload, metadata, created_at, created_by)
|
|
84
|
+
VALUES (${tenantId}, ${"test-aggregate"}, ${"00000000-0000-4000-8000-00000000aaaa"},
|
|
85
|
+
${_eventVersion}, ${type}, ${JSON.stringify(payload)}, ${"{}"}, now(), ${createdBy})
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("my-audit-log", () => {
|
|
90
|
+
test("user sieht nur seine eigenen events (cross-user-Isolation)", async () => {
|
|
91
|
+
await seedEvent(alice.id, tenantA, "user.requested-deletion", { foo: "alice" });
|
|
92
|
+
await seedEvent(bob.id, tenantA, "user.requested-deletion", { foo: "bob" });
|
|
93
|
+
|
|
94
|
+
const aliceLog = await stack.http.queryOk<{ rows: Array<{ payload: unknown }> }>(
|
|
95
|
+
MY_AUDIT,
|
|
96
|
+
{},
|
|
97
|
+
alice,
|
|
98
|
+
);
|
|
99
|
+
const bobLog = await stack.http.queryOk<{ rows: Array<{ payload: unknown }> }>(
|
|
100
|
+
MY_AUDIT,
|
|
101
|
+
{},
|
|
102
|
+
bob,
|
|
103
|
+
);
|
|
104
|
+
expect(aliceLog.rows.length).toBe(1);
|
|
105
|
+
expect(bobLog.rows.length).toBe(1);
|
|
106
|
+
// Payload-pinning beweist die Cross-User-Filterung: alice sieht nur
|
|
107
|
+
// ihre Payload, bob nur seine.
|
|
108
|
+
expect((aliceLog.rows[0]?.payload as { foo: string }).foo).toBe("alice");
|
|
109
|
+
expect((bobLog.rows[0]?.payload as { foo: string }).foo).toBe("bob");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("Account-weite Sicht: User sieht events aus anderen Tenants (DSGVO Art. 15)", async () => {
|
|
113
|
+
const tenantB = testTenantId(2);
|
|
114
|
+
await seedEvent(alice.id, tenantA, "user.x", { from: "tenantA" });
|
|
115
|
+
await seedEvent(alice.id, tenantB, "user.y", { from: "tenantB" });
|
|
116
|
+
|
|
117
|
+
const log = await stack.http.queryOk<{
|
|
118
|
+
rows: Array<{ payload: { from: string } }>;
|
|
119
|
+
}>(MY_AUDIT, {}, alice);
|
|
120
|
+
|
|
121
|
+
expect(log.rows.length).toBe(2);
|
|
122
|
+
const fromTenants = log.rows.map((r) => r.payload.from).sort();
|
|
123
|
+
expect(fromTenants).toEqual(["tenantA", "tenantB"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("filter eventType + payload kommt mit", async () => {
|
|
127
|
+
await seedEvent(alice.id, tenantA, "user.requested-deletion", { gracePeriodEnd: "2026-06-01" });
|
|
128
|
+
await seedEvent(alice.id, tenantA, "user.lifted-restriction", {});
|
|
129
|
+
|
|
130
|
+
const filtered = await stack.http.queryOk<{ rows: Array<{ type: string }> }>(
|
|
131
|
+
MY_AUDIT,
|
|
132
|
+
{ eventType: "user.requested-deletion" },
|
|
133
|
+
alice,
|
|
134
|
+
);
|
|
135
|
+
expect(filtered.rows.length).toBe(1);
|
|
136
|
+
expect(filtered.rows[0]?.type).toBe("user.requested-deletion");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("download-attempt retention :: disk-bomb-Schutz bei Brute-Force", () => {
|
|
141
|
+
test("Entity-Default ist 90d hardDelete (kein unbounded growth)", () => {
|
|
142
|
+
expect(downloadAttemptEntity.retention).toBeDefined();
|
|
143
|
+
expect(downloadAttemptEntity.retention?.keepFor).toBe("90d");
|
|
144
|
+
expect(downloadAttemptEntity.retention?.strategy).toBe("hardDelete");
|
|
145
|
+
expect(downloadAttemptEntity.retention?.reference).toBe("attemptedAt");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("list-download-attempts (DPO operator-query)", () => {
|
|
150
|
+
test("Admin kann queryen, Member nicht", async () => {
|
|
151
|
+
await seedUser(alice, "alice@example.com");
|
|
152
|
+
// Admin allowed
|
|
153
|
+
const ok = await stack.http.queryOk<{ rows: unknown[] }>(LIST_ATTEMPTS, {}, admin);
|
|
154
|
+
expect(Array.isArray(ok.rows)).toBe(true);
|
|
155
|
+
// Member blocked
|
|
156
|
+
const res = await stack.http.query(LIST_ATTEMPTS, {}, alice);
|
|
157
|
+
expect([401, 403]).toContain(res.status);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("filter result=notFound", async () => {
|
|
161
|
+
// Direct-INSERT in attempts (simuliert was die download-handler schreiben).
|
|
162
|
+
const T = await import("@cosmicdrift/kumiko-framework/time");
|
|
163
|
+
const now = T.getTemporal().Now.instant();
|
|
164
|
+
await stack.db.insert(downloadAttemptsTable).values([
|
|
165
|
+
{
|
|
166
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
167
|
+
tenantId: tenantA,
|
|
168
|
+
result: "notFound",
|
|
169
|
+
via: "token",
|
|
170
|
+
tokenHash: "abc",
|
|
171
|
+
jobId: null,
|
|
172
|
+
attemptedByUserId: null,
|
|
173
|
+
ip: "1.2.3.4",
|
|
174
|
+
userAgent: "test",
|
|
175
|
+
attemptedAt: now,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
179
|
+
tenantId: tenantA,
|
|
180
|
+
result: "expired",
|
|
181
|
+
via: "token",
|
|
182
|
+
tokenHash: "def",
|
|
183
|
+
jobId: null,
|
|
184
|
+
attemptedByUserId: null,
|
|
185
|
+
ip: "1.2.3.4",
|
|
186
|
+
userAgent: "test",
|
|
187
|
+
attemptedAt: now,
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const filtered = await stack.http.queryOk<{ rows: Array<{ result: string }> }>(
|
|
192
|
+
LIST_ATTEMPTS,
|
|
193
|
+
{ result: "notFound" },
|
|
194
|
+
admin,
|
|
195
|
+
);
|
|
196
|
+
expect(filtered.rows.length).toBe(1);
|
|
197
|
+
expect(filtered.rows[0]?.result).toBe("notFound");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Cross-Data-Matrix Integration-Test (S2.T1).
|
|
2
|
+
//
|
|
3
|
+
// Pinst dass eine App-eigene Domain-Entity ueber EXT_USER_DATA sauber in
|
|
4
|
+
// Export- + Forget-Pipeline integriert. Synthetic "note"-Entity steht
|
|
5
|
+
// stellvertretend fuer "Chat-Message", "Blog-Post", "Order-Line" etc.
|
|
6
|
+
//
|
|
7
|
+
// Matrix:
|
|
8
|
+
// - Export bundelt user + fileRef + note (3 Provider-Features) cross-
|
|
9
|
+
// tenant fuer einen User.
|
|
10
|
+
// - Forget cleant user (anonymized) + fileRef (deleted) + note
|
|
11
|
+
// (deleted) cross-tenant fuer denselben User.
|
|
12
|
+
// - Other-User-Isolation: Bobs notes/files bleiben unangetastet bei
|
|
13
|
+
// Alices Forget; Bobs Daten landen NICHT in Alices Export-Bundle.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
defineFeature,
|
|
17
|
+
EXT_USER_DATA,
|
|
18
|
+
type UserDataDeleteHook,
|
|
19
|
+
type UserDataExportHook,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import {
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
unsafeCreateEntityTable,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
27
|
+
import { sql } from "drizzle-orm";
|
|
28
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
29
|
+
import {
|
|
30
|
+
createComplianceProfilesFeature,
|
|
31
|
+
tenantComplianceProfileEntity,
|
|
32
|
+
} from "../../compliance-profiles";
|
|
33
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
34
|
+
import { createFilesFeature } from "../../files";
|
|
35
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
36
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
37
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
38
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
39
|
+
import { runUserExport } from "../run-user-export";
|
|
40
|
+
|
|
41
|
+
let stack: TestStack;
|
|
42
|
+
|
|
43
|
+
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
44
|
+
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
45
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
46
|
+
|
|
47
|
+
function uuid(suffix: number): string {
|
|
48
|
+
return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ALICE_ID = uuid(1);
|
|
52
|
+
const BOB_ID = uuid(2);
|
|
53
|
+
|
|
54
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
55
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
56
|
+
const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
57
|
+
|
|
58
|
+
// Synthetic third-party Domain-Feature: "note" mit export- + delete-Hook.
|
|
59
|
+
// Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
|
|
60
|
+
// etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
|
|
61
|
+
const exportNotes: UserDataExportHook = async (ctx) => {
|
|
62
|
+
const result = await ctx.db.execute(sql`
|
|
63
|
+
SELECT id, title, body
|
|
64
|
+
FROM test_notes
|
|
65
|
+
WHERE tenant_id = ${ctx.tenantId} AND author_id = ${ctx.userId}
|
|
66
|
+
`);
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
68
|
+
const rows = ((result as any).rows ?? result) as Array<{
|
|
69
|
+
id: string;
|
|
70
|
+
title: string;
|
|
71
|
+
body: string;
|
|
72
|
+
}>;
|
|
73
|
+
if (rows.length === 0) return null;
|
|
74
|
+
return {
|
|
75
|
+
entity: "note",
|
|
76
|
+
rows: rows.map((r) => ({ id: r.id, title: r.title, body: r.body })),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const deleteNotes: UserDataDeleteHook = async (ctx, _strategy) => {
|
|
81
|
+
await ctx.db.execute(sql`
|
|
82
|
+
DELETE FROM test_notes
|
|
83
|
+
WHERE tenant_id = ${ctx.tenantId} AND author_id = ${ctx.userId}
|
|
84
|
+
`);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const testNotesFeature = defineFeature("test-notes", (r) => {
|
|
88
|
+
r.useExtension(EXT_USER_DATA, "note", {
|
|
89
|
+
export: exportNotes,
|
|
90
|
+
delete: deleteNotes,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
stack = await setupTestStack({
|
|
96
|
+
features: [
|
|
97
|
+
createUserFeature(),
|
|
98
|
+
createFilesFeature(),
|
|
99
|
+
createDataRetentionFeature(),
|
|
100
|
+
createComplianceProfilesFeature(),
|
|
101
|
+
createUserDataRightsFeature(),
|
|
102
|
+
createUserDataRightsDefaultsFeature(),
|
|
103
|
+
testNotesFeature,
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
108
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
109
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
110
|
+
await stack.db.execute(sql`
|
|
111
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
112
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
113
|
+
tenant_id UUID NOT NULL,
|
|
114
|
+
user_id TEXT NOT NULL,
|
|
115
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
117
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
118
|
+
inserted_by_id TEXT,
|
|
119
|
+
modified_by_id TEXT,
|
|
120
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
121
|
+
deleted_at TIMESTAMPTZ,
|
|
122
|
+
deleted_by_id TEXT,
|
|
123
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
124
|
+
UNIQUE(user_id, tenant_id)
|
|
125
|
+
)
|
|
126
|
+
`);
|
|
127
|
+
await stack.db.execute(sql`
|
|
128
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
129
|
+
id UUID PRIMARY KEY,
|
|
130
|
+
tenant_id UUID NOT NULL,
|
|
131
|
+
storage_key TEXT NOT NULL,
|
|
132
|
+
file_name TEXT NOT NULL,
|
|
133
|
+
mime_type TEXT NOT NULL,
|
|
134
|
+
size INTEGER NOT NULL,
|
|
135
|
+
entity_type TEXT,
|
|
136
|
+
entity_id TEXT,
|
|
137
|
+
field_name TEXT,
|
|
138
|
+
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
139
|
+
inserted_by_id TEXT
|
|
140
|
+
)
|
|
141
|
+
`);
|
|
142
|
+
await stack.db.execute(sql`
|
|
143
|
+
CREATE TABLE IF NOT EXISTS test_notes (
|
|
144
|
+
id UUID PRIMARY KEY,
|
|
145
|
+
tenant_id UUID NOT NULL,
|
|
146
|
+
author_id TEXT NOT NULL,
|
|
147
|
+
title TEXT NOT NULL,
|
|
148
|
+
body TEXT NOT NULL
|
|
149
|
+
)
|
|
150
|
+
`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterAll(async () => {
|
|
154
|
+
await stack.cleanup();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
await stack.db.delete(userTable);
|
|
159
|
+
await stack.db.execute(sql`DELETE FROM read_tenant_memberships`);
|
|
160
|
+
await stack.db.execute(sql`DELETE FROM file_refs`);
|
|
161
|
+
await stack.db.execute(sql`DELETE FROM test_notes`);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
async function seedUser(
|
|
165
|
+
id: string,
|
|
166
|
+
overrides: {
|
|
167
|
+
status?: string;
|
|
168
|
+
gracePeriodEnd?: Instant | null;
|
|
169
|
+
email?: string;
|
|
170
|
+
displayName?: string;
|
|
171
|
+
} = {},
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
await stack.db.insert(userTable).values({
|
|
174
|
+
id,
|
|
175
|
+
tenantId: TENANT_SYSTEM,
|
|
176
|
+
email: overrides.email ?? `user-${id}@example.com`,
|
|
177
|
+
passwordHash: "hashed",
|
|
178
|
+
displayName: overrides.displayName ?? `User ${id}`,
|
|
179
|
+
locale: "de",
|
|
180
|
+
emailVerified: true,
|
|
181
|
+
roles: '["Member"]',
|
|
182
|
+
status: overrides.status ?? USER_STATUS.Active,
|
|
183
|
+
gracePeriodEnd: overrides.gracePeriodEnd ?? null,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
188
|
+
await stack.db.execute(sql`
|
|
189
|
+
INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
190
|
+
VALUES (${tenantId}, ${userId}, '["Member"]')
|
|
191
|
+
ON CONFLICT (user_id, tenant_id) DO NOTHING
|
|
192
|
+
`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function seedFileRef(
|
|
196
|
+
id: string,
|
|
197
|
+
tenantId: string,
|
|
198
|
+
userId: string,
|
|
199
|
+
name: string,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
await stack.db.execute(sql`
|
|
202
|
+
INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
203
|
+
VALUES (${id}, ${tenantId}, ${`storage/${id}`}, ${name}, 'application/pdf', 1024, ${userId})
|
|
204
|
+
`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function seedNote(
|
|
208
|
+
id: string,
|
|
209
|
+
tenantId: string,
|
|
210
|
+
userId: string,
|
|
211
|
+
title: string,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
await stack.db.execute(sql`
|
|
214
|
+
INSERT INTO test_notes (id, tenant_id, author_id, title, body)
|
|
215
|
+
VALUES (${id}, ${tenantId}, ${userId}, ${title}, ${`body for ${title}`})
|
|
216
|
+
`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function fetchNotes(tenantId: string, userId: string): Promise<unknown[]> {
|
|
220
|
+
const result = await stack.db.execute(sql`
|
|
221
|
+
SELECT id, title FROM test_notes WHERE tenant_id = ${tenantId} AND author_id = ${userId}
|
|
222
|
+
`);
|
|
223
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
224
|
+
return ((result as any).rows ?? result) as unknown[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describe("Cross-Data-Matrix :: Export bundelt 3 Provider-Features (user + fileRef + note)", () => {
|
|
228
|
+
test("Alice (Tenant A + B) → Bundle hat alle 3 Entitaeten cross-tenant", async () => {
|
|
229
|
+
await seedUser(ALICE_ID, { email: "alice@example.com", displayName: "Alice" });
|
|
230
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
231
|
+
await seedMembership(ALICE_ID, TENANT_B);
|
|
232
|
+
|
|
233
|
+
await seedFileRef(uuid(101), TENANT_A, ALICE_ID, "alice-a.pdf");
|
|
234
|
+
await seedFileRef(uuid(102), TENANT_B, ALICE_ID, "alice-b.pdf");
|
|
235
|
+
|
|
236
|
+
await seedNote(uuid(201), TENANT_A, ALICE_ID, "note-A-1");
|
|
237
|
+
await seedNote(uuid(202), TENANT_A, ALICE_ID, "note-A-2");
|
|
238
|
+
await seedNote(uuid(203), TENANT_B, ALICE_ID, "note-B-1");
|
|
239
|
+
|
|
240
|
+
const bundle = await runUserExport({
|
|
241
|
+
db: stack.db,
|
|
242
|
+
registry: stack.registry,
|
|
243
|
+
userId: ALICE_ID,
|
|
244
|
+
now: NOW(),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(bundle.tenants).toHaveLength(2);
|
|
248
|
+
|
|
249
|
+
const tenantA = bundle.tenants.find((t) => t.tenantId === TENANT_A);
|
|
250
|
+
const tenantB = bundle.tenants.find((t) => t.tenantId === TENANT_B);
|
|
251
|
+
expect(tenantA).toBeDefined();
|
|
252
|
+
expect(tenantB).toBeDefined();
|
|
253
|
+
|
|
254
|
+
// Tenant A: user + fileRef + 2 notes
|
|
255
|
+
const entitiesA = (tenantA?.entities ?? []).map((e) => e.entity);
|
|
256
|
+
expect(entitiesA).toContain("user");
|
|
257
|
+
expect(entitiesA).toContain("fileRef");
|
|
258
|
+
expect(entitiesA).toContain("note");
|
|
259
|
+
const noteSnippetA = tenantA?.entities.find((e) => e.entity === "note");
|
|
260
|
+
expect(noteSnippetA?.rows).toHaveLength(2);
|
|
261
|
+
|
|
262
|
+
// Tenant B: user + fileRef + 1 note
|
|
263
|
+
const noteSnippetB = tenantB?.entities.find((e) => e.entity === "note");
|
|
264
|
+
expect(noteSnippetB?.rows).toHaveLength(1);
|
|
265
|
+
expect(String(noteSnippetB?.rows[0]?.["title"])).toBe("note-B-1");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("Other-User-Isolation: Bobs notes nicht in Alices Bundle", async () => {
|
|
269
|
+
await seedUser(ALICE_ID, { email: "alice@example.com" });
|
|
270
|
+
await seedUser(BOB_ID, { email: "bob@example.com" });
|
|
271
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
272
|
+
await seedMembership(BOB_ID, TENANT_A);
|
|
273
|
+
|
|
274
|
+
await seedNote(uuid(301), TENANT_A, ALICE_ID, "alice-secret");
|
|
275
|
+
await seedNote(uuid(302), TENANT_A, BOB_ID, "bob-secret");
|
|
276
|
+
|
|
277
|
+
const bundle = await runUserExport({
|
|
278
|
+
db: stack.db,
|
|
279
|
+
registry: stack.registry,
|
|
280
|
+
userId: ALICE_ID,
|
|
281
|
+
now: NOW(),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const serialized = JSON.stringify(bundle);
|
|
285
|
+
expect(serialized).toContain("alice-secret");
|
|
286
|
+
expect(serialized).not.toContain("bob-secret");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("Cross-Data-Matrix :: Forget cleant 3 Provider-Features cross-tenant", () => {
|
|
291
|
+
test("Alice DeletionRequested + grace expired → notes weg in beiden Tenants, Bobs Daten unangetastet", async () => {
|
|
292
|
+
await seedUser(ALICE_ID, {
|
|
293
|
+
status: USER_STATUS.DeletionRequested,
|
|
294
|
+
gracePeriodEnd: PAST(),
|
|
295
|
+
email: "alice@example.com",
|
|
296
|
+
});
|
|
297
|
+
await seedUser(BOB_ID, { email: "bob@example.com" });
|
|
298
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
299
|
+
await seedMembership(ALICE_ID, TENANT_B);
|
|
300
|
+
await seedMembership(BOB_ID, TENANT_A);
|
|
301
|
+
|
|
302
|
+
await seedFileRef(uuid(401), TENANT_A, ALICE_ID, "alice-a.pdf");
|
|
303
|
+
await seedFileRef(uuid(402), TENANT_A, BOB_ID, "bob-a.pdf");
|
|
304
|
+
await seedNote(uuid(501), TENANT_A, ALICE_ID, "alice-A");
|
|
305
|
+
await seedNote(uuid(502), TENANT_B, ALICE_ID, "alice-B");
|
|
306
|
+
await seedNote(uuid(503), TENANT_A, BOB_ID, "bob-A");
|
|
307
|
+
|
|
308
|
+
const result = await runForgetCleanup({
|
|
309
|
+
db: stack.db,
|
|
310
|
+
registry: stack.registry,
|
|
311
|
+
now: NOW(),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
315
|
+
expect(result.errors).toHaveLength(0);
|
|
316
|
+
|
|
317
|
+
// Alices notes in beiden Tenants weg
|
|
318
|
+
expect(await fetchNotes(TENANT_A, ALICE_ID)).toHaveLength(0);
|
|
319
|
+
expect(await fetchNotes(TENANT_B, ALICE_ID)).toHaveLength(0);
|
|
320
|
+
|
|
321
|
+
// Alices fileRef in Tenant A weg
|
|
322
|
+
const aliceFiles = await stack.db.execute(sql`
|
|
323
|
+
SELECT id FROM file_refs WHERE tenant_id = ${TENANT_A} AND inserted_by_id = ${ALICE_ID}
|
|
324
|
+
`);
|
|
325
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
326
|
+
expect((((aliceFiles as any).rows ?? aliceFiles) as unknown[]).length).toBe(0);
|
|
327
|
+
|
|
328
|
+
// Bobs notes + files unangetastet
|
|
329
|
+
expect(await fetchNotes(TENANT_A, BOB_ID)).toHaveLength(1);
|
|
330
|
+
const bobFiles = await stack.db.execute(sql`
|
|
331
|
+
SELECT id FROM file_refs WHERE tenant_id = ${TENANT_A} AND inserted_by_id = ${BOB_ID}
|
|
332
|
+
`);
|
|
333
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
334
|
+
expect((((bobFiles as any).rows ?? bobFiles) as unknown[]).length).toBe(1);
|
|
335
|
+
|
|
336
|
+
// Alice-User-Row anonymisiert (DSGVO-Kern: PII raus, Sentinel-Email).
|
|
337
|
+
const aliceRow = await stack.db.execute(sql`
|
|
338
|
+
SELECT email, status FROM read_users WHERE id = ${ALICE_ID}
|
|
339
|
+
`);
|
|
340
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
341
|
+
const aliceRows = ((aliceRow as any).rows ?? aliceRow) as Array<{
|
|
342
|
+
email: string;
|
|
343
|
+
status: string;
|
|
344
|
+
}>;
|
|
345
|
+
expect(aliceRows[0]?.status).toBe(USER_STATUS.Deleted);
|
|
346
|
+
expect(aliceRows[0]?.email).toMatch(/^deleted-.*@anonymized\.invalid$/);
|
|
347
|
+
expect(aliceRows[0]?.email).not.toContain("alice@example.com");
|
|
348
|
+
});
|
|
349
|
+
});
|