@cosmicdrift/kumiko-bundled-features 0.21.0 → 0.22.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 -8
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +58 -0
- package/src/custom-fields/constants.ts +23 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -4
- package/src/custom-fields/lib/value-schema.ts +51 -17
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +144 -0
- package/src/custom-fields/web/client-plugin.tsx +25 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +181 -0
- package/src/custom-fields/web/index.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +3 -23
- package/src/files/feature.ts +7 -34
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +27 -0
- package/src/files-provider-s3/s3-provider.ts +47 -114
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +231 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -15
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +6 -18
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +3 -0
- package/src/files/schema/file-ref.ts +0 -58
package/src/files/feature.ts
CHANGED
|
@@ -1,34 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// bundled-feature, damit Cross-Feature-Hooks (userData, tenantData) sich
|
|
9
|
-
// an die "fileRef"-Entity hängen können.
|
|
10
|
-
//
|
|
11
|
-
// Sprint 1.5 (this commit):
|
|
12
|
-
// - r.entity("fileRef", fileRefEntity) — Schema-Surface
|
|
13
|
-
// - r.defineEvent("uploaded", schema) — Event-Marker
|
|
14
|
-
//
|
|
15
|
-
// Sprint 2 (kommt):
|
|
16
|
-
// - r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
|
|
17
|
-
//
|
|
18
|
-
// Sprint 5 (kommt):
|
|
19
|
-
// - r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
|
|
20
|
-
//
|
|
21
|
-
// Routes bleiben framework-internal (multipart-Upload + binary-Streaming
|
|
22
|
-
// passen nicht in das Handler-Pattern; siehe schema/file-ref.ts für
|
|
23
|
-
// Architektur-Note).
|
|
24
|
-
//
|
|
25
|
-
// Sprint-1.5-Plan-Roadmap-Wille: "fileRefsTable bleibt in framework
|
|
26
|
-
// (kein Daten-Move), aber r.entity('fileRef') deklariert sie für das
|
|
27
|
-
// Feature." — diese Datei IST die Umsetzung.
|
|
28
|
-
export function createFilesFeature(): FeatureDefinition {
|
|
29
|
-
return defineFeature("files", (r) => {
|
|
30
|
-
r.entity("fileRef", fileRefEntity);
|
|
31
|
-
|
|
32
|
-
r.defineEvent("uploaded", fileUploadedPayloadSchema);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
1
|
+
// files — full Event-Sourcing für File-Metadata. Die Implementierung (Entity,
|
|
2
|
+
// files:event:*-Events, Inline-Projektion) lebt seit dem ES-Umbau im
|
|
3
|
+
// Framework neben file-routes + fileRefsTable, weil file-routes hart davon
|
|
4
|
+
// abhängt (appendDomainEventCore verlangt registrierte Events + Projektion).
|
|
5
|
+
// Dieses Modul re-exportiert nur, damit der App-Import-Pfad
|
|
6
|
+
// `@cosmicdrift/kumiko-bundled-features/files` stabil bleibt.
|
|
7
|
+
export { createFilesFeature, fileRefEntity } from "@cosmicdrift/kumiko-framework/files";
|
|
@@ -110,6 +110,33 @@ describe("s3-provider (Minio)", () => {
|
|
|
110
110
|
const key = uniqueKey("never-existed.bin");
|
|
111
111
|
await expect(provider.read(key)).rejects.toThrow();
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test("writeStream round-trip via multipart writer preserves bytes", async () => {
|
|
115
|
+
// Pinst die idiomatic Bun-S3-writer-Form (write + end, kein manual
|
|
116
|
+
// flush). Chunks summieren absichtlich auf > 5 MiB (partSize) UND auf
|
|
117
|
+
// einen krummen Rest, damit der multipart-finalizer auch dann greift,
|
|
118
|
+
// wenn die Source-Chunks nicht auf die Part-Boundary aufgehen.
|
|
119
|
+
const key = uniqueKey("stream-multipart.bin");
|
|
120
|
+
const partSize = 5 * 1024 * 1024;
|
|
121
|
+
const chunk = new Uint8Array(1024 * 1024);
|
|
122
|
+
for (let i = 0; i < chunk.length; i++) chunk[i] = i % 251;
|
|
123
|
+
const chunks: Uint8Array[] = [];
|
|
124
|
+
for (let i = 0; i < 7; i++) chunks.push(chunk);
|
|
125
|
+
|
|
126
|
+
if (!provider.writeStream) throw new Error("s3 provider should implement writeStream");
|
|
127
|
+
await provider.writeStream(
|
|
128
|
+
key,
|
|
129
|
+
(async function* () {
|
|
130
|
+
for (const c of chunks) yield c;
|
|
131
|
+
})(),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const readBack = await provider.read(key);
|
|
135
|
+
expect(readBack.byteLength).toBe(chunks.length * chunk.length);
|
|
136
|
+
expect(readBack.byteLength).toBeGreaterThan(partSize);
|
|
137
|
+
expect(readBack[0]).toBe(0);
|
|
138
|
+
expect(readBack[readBack.byteLength - 1]).toBe(chunk[chunk.length - 1]);
|
|
139
|
+
});
|
|
113
140
|
});
|
|
114
141
|
|
|
115
142
|
describe("createS3ProviderFromEnv", () => {
|
|
@@ -1,29 +1,15 @@
|
|
|
1
|
-
import { Readable } from "node:stream";
|
|
2
|
-
import {
|
|
3
|
-
DeleteObjectCommand,
|
|
4
|
-
GetObjectCommand,
|
|
5
|
-
HeadObjectCommand,
|
|
6
|
-
PutObjectCommand,
|
|
7
|
-
S3Client,
|
|
8
|
-
} from "@aws-sdk/client-s3";
|
|
9
|
-
import { Upload } from "@aws-sdk/lib-storage";
|
|
10
|
-
import { getSignedUrl as presign } from "@aws-sdk/s3-request-presigner";
|
|
11
1
|
import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-framework/files";
|
|
12
2
|
|
|
13
3
|
// =============================================================================
|
|
14
4
|
// Operator-Pflicht-Setup (Multipart-Upload-Cleanup)
|
|
15
5
|
// =============================================================================
|
|
16
6
|
//
|
|
17
|
-
// `writeStream` nutzt
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// wird (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Multipart-
|
|
24
|
-
// Upload-Session in S3 OFFEN. S3 behaelt die bereits hochgeladenen Parts
|
|
25
|
-
// und berechnet Storage-Kosten dafuer — bis sie manuell oder via Lifecycle-
|
|
26
|
-
// Rule abgebrochen werden.
|
|
7
|
+
// `writeStream` nutzt Bun's S3-Writer fuer echtes multipart-streaming. S3
|
|
8
|
+
// created dabei eine Multipart-Upload-Session mit einer Upload-ID; bei
|
|
9
|
+
// normaler Completion wird sie geschlossen. Wird der Export-Worker mid-write
|
|
10
|
+
// gecancelt (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Session
|
|
11
|
+
// in S3 OFFEN und berechnet Storage-Kosten fuer die bereits hochgeladenen
|
|
12
|
+
// Parts — bis sie via Lifecycle-Rule abgebrochen werden.
|
|
27
13
|
//
|
|
28
14
|
// **Pflicht-Operator-Setup auf jedem Bucket:**
|
|
29
15
|
//
|
|
@@ -39,16 +25,9 @@ import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-
|
|
|
39
25
|
// AWS-CLI: `aws s3api put-bucket-lifecycle-configuration --bucket <name>
|
|
40
26
|
// --lifecycle-configuration file://lifecycle.json`. Hetzner Object Storage
|
|
41
27
|
// + R2 + Minio supporten dieselbe Syntax.
|
|
42
|
-
//
|
|
43
|
-
// **Code-side abort()** fuer graceful Worker-Shutdown ist follow-up. Das
|
|
44
|
-
// braucht Worker-Cancel-Semantik (AbortSignal-Propagation durch r.job),
|
|
45
|
-
// die im framework noch nicht existiert. Bis dahin ist die Lifecycle-
|
|
46
|
-
// Rule die einzige Garantie gegen Storage-Leakage.
|
|
47
28
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// the same way they'd pass createLocalProvider in dev.
|
|
51
|
-
//
|
|
29
|
+
const STREAM_PART_SIZE = 5 * 1024 * 1024;
|
|
30
|
+
|
|
52
31
|
// `endpoint` + `forcePathStyle` are the R2/Minio knobs: AWS-S3 uses
|
|
53
32
|
// virtual-host-style URLs (bucket.s3.region.amazonaws.com), Minio and many
|
|
54
33
|
// S3-compat providers need path-style (endpoint/bucket/key). Default
|
|
@@ -67,8 +46,7 @@ export type S3ProviderConfig = {
|
|
|
67
46
|
|
|
68
47
|
// Exported for unit testing — the branch logic (explicit override vs.
|
|
69
48
|
// auto-detect from endpoint) is small but load-bearing: Minio/R2 break
|
|
70
|
-
// silently if the virtual-host-style is picked.
|
|
71
|
-
// without constructing an S3Client means the rule stays honest.
|
|
49
|
+
// silently if the virtual-host-style is picked.
|
|
72
50
|
export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
|
|
73
51
|
// Explicit override wins; otherwise: custom endpoint → path-style
|
|
74
52
|
// (that's the shape every non-AWS S3-compatible provider expects),
|
|
@@ -77,85 +55,53 @@ export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
|
|
|
77
55
|
}
|
|
78
56
|
|
|
79
57
|
export function createS3Provider(config: S3ProviderConfig): FileStorageProvider {
|
|
80
|
-
const client = new S3Client({
|
|
58
|
+
const client = new Bun.S3Client({
|
|
81
59
|
region: config.region,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
},
|
|
60
|
+
accessKeyId: config.accessKeyId,
|
|
61
|
+
secretAccessKey: config.secretAccessKey,
|
|
62
|
+
bucket: config.bucket,
|
|
86
63
|
...(config.endpoint !== undefined && { endpoint: config.endpoint }),
|
|
87
|
-
forcePathStyle
|
|
64
|
+
// Bun's virtualHostedStyle is the inverse of the AWS-SDK forcePathStyle
|
|
65
|
+
// knob this config exposes: path-style ⇔ virtualHostedStyle=false.
|
|
66
|
+
virtualHostedStyle: !resolveForcePathStyle(config),
|
|
88
67
|
});
|
|
89
68
|
|
|
90
69
|
return {
|
|
91
70
|
async write(key, data, mimeType): Promise<void> {
|
|
92
|
-
await client.
|
|
93
|
-
new PutObjectCommand({
|
|
94
|
-
Bucket: config.bucket,
|
|
95
|
-
Key: key,
|
|
96
|
-
Body: data,
|
|
97
|
-
...(mimeType !== undefined && { ContentType: mimeType }),
|
|
98
|
-
}),
|
|
99
|
-
);
|
|
71
|
+
await client.write(key, data, mimeType !== undefined ? { type: mimeType } : undefined);
|
|
100
72
|
},
|
|
101
73
|
|
|
102
74
|
async writeStream(key, source, options): Promise<void> {
|
|
103
|
-
// Echtes multipart-streaming via
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Readable.from(source) adapiert AsyncIterable → node:Readable —
|
|
114
|
-
// lib-storage's Body-Type akzeptiert Web-ReadableStream + node-
|
|
115
|
-
// Readable, nicht direkt AsyncIterable. Adapter ist zero-copy.
|
|
116
|
-
const body = Readable.from(source);
|
|
117
|
-
const upload = new Upload({
|
|
118
|
-
client,
|
|
119
|
-
params: {
|
|
120
|
-
Bucket: config.bucket,
|
|
121
|
-
Key: key,
|
|
122
|
-
Body: body,
|
|
123
|
-
...(options?.mimeType !== undefined && { ContentType: options.mimeType }),
|
|
124
|
-
},
|
|
75
|
+
// Echtes multipart-streaming via Bun's S3-Writer — partSize steuert die
|
|
76
|
+
// Part-Boundary intern (AWS/R2 verlangen non-final Parts >= 5 MiB,
|
|
77
|
+
// sonst EntityTooSmall beim CompleteMultipartUpload). Manuelles flush()
|
|
78
|
+
// hier wuerde genau diese Garantie brechen, sobald die Source-Chunks
|
|
79
|
+
// nicht auf partSize aufgehen.
|
|
80
|
+
const writer = client.file(key).writer({
|
|
81
|
+
...(options?.mimeType !== undefined && { type: options.mimeType }),
|
|
82
|
+
retry: 3,
|
|
83
|
+
queueSize: 4,
|
|
84
|
+
partSize: STREAM_PART_SIZE,
|
|
125
85
|
});
|
|
126
|
-
await
|
|
86
|
+
for await (const chunk of source) {
|
|
87
|
+
// Await applies Backpressure und bounded die in-flight Queue auf
|
|
88
|
+
// queueSize, statt unbegrenzt zu puffern.
|
|
89
|
+
await writer.write(chunk);
|
|
90
|
+
}
|
|
91
|
+
await writer.end();
|
|
127
92
|
},
|
|
128
93
|
|
|
129
94
|
async read(key): Promise<Uint8Array> {
|
|
130
|
-
|
|
131
|
-
if (!response.Body) {
|
|
132
|
-
throw new Error(`s3_read_empty_body: ${key}`);
|
|
133
|
-
}
|
|
134
|
-
// transformToByteArray is the stream-to-bytes helper the v3 SDK ships
|
|
135
|
-
// with — avoids us reinventing a ReadableStream reader. Returns a
|
|
136
|
-
// Uint8Array, which is what FileStorageProvider.read() promises.
|
|
137
|
-
return response.Body.transformToByteArray();
|
|
95
|
+
return new Uint8Array(await client.file(key).arrayBuffer());
|
|
138
96
|
},
|
|
139
97
|
|
|
140
98
|
readStream(key): AsyncIterable<Uint8Array> {
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// GetObject-Request abgesetzt. Wenn der Key nicht existiert, faellt
|
|
145
|
-
// der Error genau dort (nicht beim readStream-Aufruf) — gleiches
|
|
146
|
-
// Lazy-Verhalten wie inmemory + local.
|
|
99
|
+
// Lazy: erst beim ersten chunk-pull wird der GET-Request abgesetzt.
|
|
100
|
+
// Existiert der Key nicht, faellt der Error genau dort (nicht beim
|
|
101
|
+
// readStream-Aufruf) — gleiches Lazy-Verhalten wie inmemory + local.
|
|
147
102
|
return {
|
|
148
103
|
async *[Symbol.asyncIterator]() {
|
|
149
|
-
const
|
|
150
|
-
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
151
|
-
);
|
|
152
|
-
if (!response.Body) {
|
|
153
|
-
throw new Error(`s3_read_empty_body: ${key}`);
|
|
154
|
-
}
|
|
155
|
-
// SdkStream is AsyncIterable<Buffer> on node. Buffer extends
|
|
156
|
-
// Uint8Array; cast sichert die Surface ohne neue runtime-deps.
|
|
157
|
-
const body = response.Body as AsyncIterable<Uint8Array>; // @cast-boundary engine-bridge
|
|
158
|
-
for await (const chunk of body) {
|
|
104
|
+
for await (const chunk of client.file(key).stream()) {
|
|
159
105
|
yield chunk;
|
|
160
106
|
}
|
|
161
107
|
},
|
|
@@ -163,23 +109,11 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
163
109
|
},
|
|
164
110
|
|
|
165
111
|
async delete(key): Promise<void> {
|
|
166
|
-
await client.
|
|
112
|
+
await client.delete(key);
|
|
167
113
|
},
|
|
168
114
|
|
|
169
115
|
async exists(key): Promise<boolean> {
|
|
170
|
-
|
|
171
|
-
await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
172
|
-
return true;
|
|
173
|
-
} catch (error) {
|
|
174
|
-
// S3 SDK throws either NotFound or a generic 404. Check both the
|
|
175
|
-
// `.name` property (newer SDKs) and the `$metadata.httpStatusCode`
|
|
176
|
-
// (what the SDK guarantees on every error).
|
|
177
|
-
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } }; // @cast-boundary error-details
|
|
178
|
-
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
throw error;
|
|
182
|
-
}
|
|
116
|
+
return client.exists(key);
|
|
183
117
|
},
|
|
184
118
|
|
|
185
119
|
async getSignedUrl(
|
|
@@ -187,17 +121,16 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
187
121
|
expiresInSeconds: number,
|
|
188
122
|
options?: SignedUrlOptions,
|
|
189
123
|
): Promise<string> {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
124
|
+
// contentDisposition wird von Bun als response-content-disposition
|
|
125
|
+
// Query-Param signiert (Response-Override fuer den GET-Download) —
|
|
126
|
+
// der Browser sieht den Original-Dateinamen statt des UUID-Keys.
|
|
127
|
+
return client.presign(key, {
|
|
128
|
+
expiresIn: expiresInSeconds,
|
|
129
|
+
method: "GET",
|
|
196
130
|
...(options?.contentDisposition !== undefined && {
|
|
197
|
-
|
|
131
|
+
contentDisposition: options.contentDisposition,
|
|
198
132
|
}),
|
|
199
133
|
});
|
|
200
|
-
return presign(client, command, { expiresIn: expiresInSeconds });
|
|
201
134
|
},
|
|
202
135
|
};
|
|
203
136
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
setupTestStack,
|
|
26
26
|
type TestStack,
|
|
27
27
|
unsafeCreateEntityTable,
|
|
28
|
+
unsafePushTables,
|
|
28
29
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
30
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
30
31
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -145,21 +146,9 @@ beforeAll(async () => {
|
|
|
145
146
|
UNIQUE(user_id, tenant_id)
|
|
146
147
|
)
|
|
147
148
|
`);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
tenant_id UUID NOT NULL,
|
|
152
|
-
storage_key TEXT NOT NULL,
|
|
153
|
-
file_name TEXT NOT NULL,
|
|
154
|
-
mime_type TEXT NOT NULL,
|
|
155
|
-
size INTEGER NOT NULL,
|
|
156
|
-
entity_type TEXT,
|
|
157
|
-
entity_id TEXT,
|
|
158
|
-
field_name TEXT,
|
|
159
|
-
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
160
|
-
inserted_by_id TEXT
|
|
161
|
-
)
|
|
162
|
-
`);
|
|
149
|
+
// fileRef ist buildEntityTable-getrieben (softDelete) — echte Entity-Tabelle
|
|
150
|
+
// pushen statt hand-CREATE, damit is_deleted/deleted_at/deleted_by_id da sind.
|
|
151
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
163
152
|
await asRawClient(stack.db).unsafe(`
|
|
164
153
|
CREATE TABLE IF NOT EXISTS test_notes (
|
|
165
154
|
id UUID PRIMARY KEY,
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// File-Retention Integration-Test.
|
|
2
|
+
//
|
|
3
|
+
// Beweist, dass die BESTEHENDE data-retention + Forget-Pipeline auch für
|
|
4
|
+
// `fileRef` greift — kein file-spezifischer Retention-Mechanismus. fileRef ist
|
|
5
|
+
// ein normales softDelete-ES-Entity; sein Forget-/Retention-Verhalten kommt
|
|
6
|
+
// aus genau derselben Kette wie bei jedem anderen Entity:
|
|
7
|
+
//
|
|
8
|
+
// runForgetCleanup → resolveRetentionPolicyForTenant(entityName="fileRef")
|
|
9
|
+
// → policyToStrategy → fileRef userData delete-Hook (delete | anonymize)
|
|
10
|
+
//
|
|
11
|
+
// Abgedeckt:
|
|
12
|
+
// 1. Default (keine Override-Policy) → Forget HARD-löscht die Datei (Art. 17).
|
|
13
|
+
// 2. Tenant-Retention-Override fileRef→anonymize → Forget anonymisiert
|
|
14
|
+
// (insertedById=null, Row bleibt) statt zu löschen.
|
|
15
|
+
// 3. Per-Tenant: derselbe User, anonymize in Tenant A, delete in Tenant B —
|
|
16
|
+
// die Policy entscheidet pro Tenant.
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import {
|
|
21
|
+
createEventStoreExecutor,
|
|
22
|
+
createTenantDb,
|
|
23
|
+
type DbConnection,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
26
|
+
import {
|
|
27
|
+
setupTestStack,
|
|
28
|
+
type TestStack,
|
|
29
|
+
TestUsers,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
unsafePushTables,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
33
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
34
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
35
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
37
|
+
import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
|
|
38
|
+
import { createFilesFeature } from "../../files";
|
|
39
|
+
import { createSessionsFeature } from "../../sessions";
|
|
40
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
41
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
42
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
43
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
44
|
+
|
|
45
|
+
let stack: TestStack;
|
|
46
|
+
let db: DbConnection;
|
|
47
|
+
|
|
48
|
+
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
49
|
+
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
50
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
51
|
+
|
|
52
|
+
function uuid(suffix: number): string {
|
|
53
|
+
return `aaaaaaaa-aaaa-4aaa-8aaa-${suffix.toString(16).padStart(12, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
57
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
58
|
+
function pastInstant(): Instant {
|
|
59
|
+
return getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
stack = await setupTestStack({
|
|
66
|
+
features: [
|
|
67
|
+
createUserFeature(),
|
|
68
|
+
createFilesFeature(),
|
|
69
|
+
createDataRetentionFeature(),
|
|
70
|
+
createComplianceProfilesFeature(),
|
|
71
|
+
createSessionsFeature(),
|
|
72
|
+
createUserDataRightsFeature(),
|
|
73
|
+
createUserDataRightsDefaultsFeature(),
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
db = stack.db;
|
|
77
|
+
|
|
78
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
79
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
80
|
+
await unsafePushTables(db, { fileRefsTable });
|
|
81
|
+
await asRawClient(db).unsafe(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
83
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
84
|
+
tenant_id UUID NOT NULL,
|
|
85
|
+
user_id TEXT NOT NULL,
|
|
86
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
88
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
89
|
+
inserted_by_id TEXT,
|
|
90
|
+
modified_by_id TEXT,
|
|
91
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
92
|
+
deleted_at TIMESTAMPTZ,
|
|
93
|
+
deleted_by_id TEXT,
|
|
94
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
95
|
+
UNIQUE(user_id, tenant_id)
|
|
96
|
+
)
|
|
97
|
+
`);
|
|
98
|
+
|
|
99
|
+
overrideExecutor = createEventStoreExecutor(
|
|
100
|
+
tenantRetentionOverrideTable,
|
|
101
|
+
tenantRetentionOverrideEntity,
|
|
102
|
+
{ entityName: "tenant-retention-override" },
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(async () => {
|
|
107
|
+
await stack.cleanup();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
await resetTestTables(db, [
|
|
112
|
+
userTable,
|
|
113
|
+
"read_tenant_memberships",
|
|
114
|
+
fileRefsTable,
|
|
115
|
+
tenantRetentionOverrideTable,
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
async function seedForgetUser(id: string): Promise<void> {
|
|
120
|
+
await insertOne(db, userTable, {
|
|
121
|
+
id,
|
|
122
|
+
tenantId: TENANT_SYSTEM,
|
|
123
|
+
email: `user-${id}@example.com`,
|
|
124
|
+
passwordHash: "hashed",
|
|
125
|
+
displayName: `User ${id}`,
|
|
126
|
+
locale: "de",
|
|
127
|
+
emailVerified: true,
|
|
128
|
+
roles: '["Member"]',
|
|
129
|
+
status: USER_STATUS.DeletionRequested,
|
|
130
|
+
gracePeriodEnd: pastInstant(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
135
|
+
await asRawClient(db).unsafe(
|
|
136
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
137
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
138
|
+
[tenantId, userId],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function seedFileRef(id: string, tenantId: string, insertedById: string): Promise<void> {
|
|
143
|
+
await asRawClient(db).unsafe(
|
|
144
|
+
`INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
145
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 1024, $5) ON CONFLICT (id) DO NOTHING`,
|
|
146
|
+
[id, tenantId, `storage/${id}`, `${id}.pdf`, insertedById],
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Setzt einen Tenant-Retention-Override über die GLEICHE API die der
|
|
151
|
+
// Forget-Resolver liest — kein Test-Sonderpfad.
|
|
152
|
+
async function seedFileRetentionOverride(
|
|
153
|
+
tenantId: string,
|
|
154
|
+
config: { keepFor: string; strategy: string; reference?: string },
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const by = { ...TestUsers.systemAdmin, tenantId };
|
|
157
|
+
const result = await overrideExecutor.create(
|
|
158
|
+
{ entityName: "fileRef", config: JSON.stringify(config), reason: "test", tenantId },
|
|
159
|
+
by,
|
|
160
|
+
createTenantDb(db, tenantId, "system"),
|
|
161
|
+
);
|
|
162
|
+
if (!result.isSuccess)
|
|
163
|
+
throw new Error(`seedFileRetentionOverride failed: ${JSON.stringify(result)}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function fetchFileRow(
|
|
167
|
+
id: string,
|
|
168
|
+
): Promise<{ id: string; inserted_by_id: string | null; is_deleted: boolean } | null> {
|
|
169
|
+
const result = await asRawClient(db).unsafe(
|
|
170
|
+
`SELECT id, inserted_by_id, is_deleted FROM file_refs WHERE id = $1`,
|
|
171
|
+
[id],
|
|
172
|
+
);
|
|
173
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
174
|
+
const rows = ((result as any).rows ?? result) as Array<{
|
|
175
|
+
id: string;
|
|
176
|
+
inserted_by_id: string | null;
|
|
177
|
+
is_deleted: boolean;
|
|
178
|
+
}>;
|
|
179
|
+
return rows[0] ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
describe("file-retention :: Forget-Pipeline greift für fileRef", () => {
|
|
183
|
+
test("Default (keine Override-Policy) → Datei wird hart gelöscht (Art. 17)", async () => {
|
|
184
|
+
const userId = uuid(1);
|
|
185
|
+
await seedForgetUser(userId);
|
|
186
|
+
await seedMembership(userId, TENANT_B);
|
|
187
|
+
await seedFileRef(uuid(101), TENANT_B, userId);
|
|
188
|
+
|
|
189
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
190
|
+
|
|
191
|
+
expect(result.processedUserIds).toContain(userId);
|
|
192
|
+
// Hard-Delete: Row weg.
|
|
193
|
+
expect(await fetchFileRow(uuid(101))).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("Retention-Override fileRef→anonymize → Datei wird anonymisiert, Row bleibt", async () => {
|
|
197
|
+
const userId = uuid(2);
|
|
198
|
+
await seedForgetUser(userId);
|
|
199
|
+
await seedMembership(userId, TENANT_A);
|
|
200
|
+
await seedFileRef(uuid(201), TENANT_A, userId);
|
|
201
|
+
await seedFileRetentionOverride(TENANT_A, { keepFor: "30d", strategy: "anonymize" });
|
|
202
|
+
|
|
203
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
204
|
+
|
|
205
|
+
expect(result.processedUserIds).toContain(userId);
|
|
206
|
+
const row = await fetchFileRow(uuid(201));
|
|
207
|
+
// Anonymize: Row existiert weiter, aber ohne Personenbezug (insertedById null).
|
|
208
|
+
expect(row).not.toBeNull();
|
|
209
|
+
expect(row?.inserted_by_id).toBeNull();
|
|
210
|
+
expect(row?.is_deleted).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Per-Tenant: derselbe User → anonymize in A, hard-delete in B", async () => {
|
|
214
|
+
const userId = uuid(3);
|
|
215
|
+
await seedForgetUser(userId);
|
|
216
|
+
await seedMembership(userId, TENANT_A);
|
|
217
|
+
await seedMembership(userId, TENANT_B);
|
|
218
|
+
await seedFileRef(uuid(301), TENANT_A, userId);
|
|
219
|
+
await seedFileRef(uuid(302), TENANT_B, userId);
|
|
220
|
+
await seedFileRetentionOverride(TENANT_A, { keepFor: "30d", strategy: "anonymize" });
|
|
221
|
+
// TENANT_B: kein Override → Default-Delete.
|
|
222
|
+
|
|
223
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
224
|
+
|
|
225
|
+
const aRow = await fetchFileRow(uuid(301));
|
|
226
|
+
expect(aRow).not.toBeNull();
|
|
227
|
+
expect(aRow?.inserted_by_id).toBeNull();
|
|
228
|
+
|
|
229
|
+
expect(await fetchFileRow(uuid(302))).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
setupTestStack,
|
|
23
23
|
type TestStack,
|
|
24
24
|
unsafeCreateEntityTable,
|
|
25
|
+
unsafePushTables,
|
|
25
26
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
27
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
28
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -92,21 +93,9 @@ beforeAll(async () => {
|
|
|
92
93
|
UNIQUE(user_id, tenant_id)
|
|
93
94
|
)
|
|
94
95
|
`);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
tenant_id UUID NOT NULL,
|
|
99
|
-
storage_key TEXT NOT NULL,
|
|
100
|
-
file_name TEXT NOT NULL,
|
|
101
|
-
mime_type TEXT NOT NULL,
|
|
102
|
-
size INTEGER NOT NULL,
|
|
103
|
-
entity_type TEXT,
|
|
104
|
-
entity_id TEXT,
|
|
105
|
-
field_name TEXT,
|
|
106
|
-
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
107
|
-
inserted_by_id TEXT
|
|
108
|
-
)
|
|
109
|
-
`);
|
|
96
|
+
// fileRef ist buildEntityTable-getrieben (softDelete) — echte Entity-Tabelle
|
|
97
|
+
// pushen statt hand-CREATE, damit is_deleted/deleted_at/deleted_by_id da sind.
|
|
98
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
110
99
|
});
|
|
111
100
|
|
|
112
101
|
afterAll(async () => {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setupTestStack,
|
|
22
22
|
type TestStack,
|
|
23
23
|
unsafeCreateEntityTable,
|
|
24
|
+
unsafePushTables,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
26
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
26
27
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -79,21 +80,9 @@ beforeAll(async () => {
|
|
|
79
80
|
UNIQUE(user_id, tenant_id)
|
|
80
81
|
)
|
|
81
82
|
`);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
tenant_id UUID NOT NULL,
|
|
86
|
-
storage_key TEXT NOT NULL,
|
|
87
|
-
file_name TEXT NOT NULL,
|
|
88
|
-
mime_type TEXT NOT NULL,
|
|
89
|
-
size INTEGER NOT NULL,
|
|
90
|
-
entity_type TEXT,
|
|
91
|
-
entity_id TEXT,
|
|
92
|
-
field_name TEXT,
|
|
93
|
-
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
94
|
-
inserted_by_id TEXT
|
|
95
|
-
)
|
|
96
|
-
`);
|
|
83
|
+
// fileRef ist buildEntityTable-getrieben (softDelete) — echte Entity-Tabelle
|
|
84
|
+
// pushen statt hand-CREATE, damit is_deleted/deleted_at/deleted_by_id da sind.
|
|
85
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
97
86
|
});
|
|
98
87
|
|
|
99
88
|
afterAll(async () => {
|