@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,1124 @@
1
+ // runExportJobs Worker Integration-Test (S2.U3 Atom 3b).
2
+ //
3
+ // Pinst die Worker-Pipeline end-to-end mit dem in-memory-Storage-Provider:
4
+ //
5
+ // 1. Happy path: pending Job → Worker pickt → Bundle gebaut → ZIP an
6
+ // Storage geschrieben → Job=done mit downloadStorageKey + expiresAt
7
+ // + bytesWritten
8
+ // 2. ZIP wirklich entpackbar (Info-ZIP) und enthaelt das Bundle
9
+ // 3. Stale-Detection: running-Job ueber TTL → failed
10
+ // 4. Worker-Throw: failing Hook → Job=failed mit errorMessage
11
+ // 5. Storage-Cleanup: done-Job mit expiresAt+grace im Past →
12
+ // downloadStorageKey wird genullt + storage-key geloescht
13
+ // 6. Idempotency: 2× run → kein Re-Processing von done/failed-Jobs
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
+ import {
21
+ createInMemoryFileProvider,
22
+ type FileStorageProvider,
23
+ } from "@cosmicdrift/kumiko-framework/files";
24
+ import {
25
+ createTestUser,
26
+ setupTestStack,
27
+ type TestStack,
28
+ testTenantId,
29
+ unsafeCreateEntityTable,
30
+ } from "@cosmicdrift/kumiko-framework/stack";
31
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
32
+ import { sql } from "drizzle-orm";
33
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
34
+ import {
35
+ createComplianceProfilesFeature,
36
+ tenantComplianceProfileEntity,
37
+ } from "../../compliance-profiles";
38
+ import { createDataRetentionFeature } from "../../data-retention";
39
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
40
+ import { createUserDataRightsFeature } from "../feature";
41
+ import { runExportJobs } from "../run-export-jobs";
42
+ import { exportDownloadTokenEntity, exportDownloadTokensTable } from "../schema/download-token";
43
+ import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "../schema/export-job";
44
+ import { hashDownloadToken } from "../token-helpers";
45
+
46
+ let stack: TestStack;
47
+ let providerPerTenant: Map<string, ReturnType<typeof createInMemoryFileProvider>>;
48
+
49
+ const tenantA = testTenantId(1);
50
+ const aliceUser = createTestUser({ id: 42, tenantId: tenantA, roles: ["Member"] });
51
+
52
+ beforeAll(async () => {
53
+ stack = await setupTestStack({
54
+ features: [
55
+ createUserFeature(),
56
+ createDataRetentionFeature(),
57
+ createComplianceProfilesFeature(),
58
+ createUserDataRightsFeature(),
59
+ ],
60
+ });
61
+ await unsafeCreateEntityTable(stack.db, exportJobEntity);
62
+ await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
63
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
64
+ await unsafeCreateEntityTable(stack.db, userEntity);
65
+ await createEventsTable(stack.db);
66
+ // tenant-membership-table fuer runUserExport's Cross-Tenant-Iteration.
67
+ // Pattern matched user-data-rights-defaults integration-test.
68
+ await stack.db.execute(sql`
69
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
70
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
71
+ tenant_id UUID NOT NULL,
72
+ user_id TEXT NOT NULL,
73
+ version INTEGER NOT NULL DEFAULT 0,
74
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
75
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
76
+ inserted_by_id TEXT,
77
+ modified_by_id TEXT,
78
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
79
+ deleted_at TIMESTAMPTZ,
80
+ deleted_by_id TEXT,
81
+ roles TEXT NOT NULL DEFAULT '[]',
82
+ UNIQUE(user_id, tenant_id)
83
+ )
84
+ `);
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await stack.cleanup();
89
+ });
90
+
91
+ beforeEach(async () => {
92
+ await stack.db.delete(exportDownloadTokensTable);
93
+ await stack.db.delete(exportJobsTable);
94
+ await stack.db.delete(userTable);
95
+ await stack.db.execute(sql`DELETE FROM kumiko_events`);
96
+ await stack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
97
+ await stack.db.execute(sql`DELETE FROM read_tenant_memberships`);
98
+ providerPerTenant = new Map();
99
+
100
+ // Atom 5: aliceUser-Row mit email seeden — Worker-Notification-Callback
101
+ // schaut email via lookupUserEmail an.
102
+ await stack.db
103
+ .insert(userTable)
104
+ .values({
105
+ id: String(aliceUser.id),
106
+ tenantId: tenantA,
107
+ email: "alice@example.com",
108
+ passwordHash: "hashed",
109
+ displayName: "Alice",
110
+ locale: "de",
111
+ emailVerified: true,
112
+ roles: '["Member"]',
113
+ status: USER_STATUS.Active,
114
+ })
115
+ .onConflictDoNothing();
116
+ });
117
+
118
+ const NOW = () => getTemporal().Now.instant();
119
+
120
+ function buildProvider(tenantId: string): Promise<FileStorageProvider> {
121
+ let p = providerPerTenant.get(tenantId);
122
+ if (!p) {
123
+ p = createInMemoryFileProvider();
124
+ providerPerTenant.set(tenantId, p);
125
+ }
126
+ return Promise.resolve(p);
127
+ }
128
+
129
+ // Seedet einen pending Job via realen request-export-Handler — echter
130
+ // ES-Pfad (crud.create emittiert event "export-job.created"), nicht
131
+ // direct-INSERT. Direct-INSERT wuerde stream-version=0/row-version=1
132
+ // drift erzeugen + Worker-claim mit version-conflict failen.
133
+ async function seedPendingJob(opts: { user?: typeof aliceUser } = {}): Promise<string> {
134
+ const result = await stack.http.writeOk<{ jobId: string }>(
135
+ "user-data-rights:write:request-export",
136
+ {},
137
+ opts.user ?? aliceUser,
138
+ );
139
+ return result.jobId;
140
+ }
141
+
142
+ describe("runExportJobs :: happy path", () => {
143
+ test("pending Job → Worker pickt → done mit downloadStorageKey + bytesWritten", async () => {
144
+ const jobId = await seedPendingJob();
145
+
146
+ const result = await runExportJobs({
147
+ db: stack.db,
148
+ registry: stack.registry,
149
+ buildStorageProvider: buildProvider,
150
+ now: NOW(),
151
+ });
152
+
153
+ expect(result.completedJobIds).toContain(jobId);
154
+ expect(result.errors).toEqual([]);
155
+
156
+ // Job-Row ist done
157
+ const [row] = (await stack.db
158
+ .select()
159
+ .from(exportJobsTable)
160
+ .where(sql`id = ${jobId}`)) as Array<{
161
+ status: string;
162
+ downloadStorageKey: string | null;
163
+ expiresAt: { toString(): string } | null;
164
+ bytesWritten: number | null;
165
+ }>;
166
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
167
+ expect(row?.downloadStorageKey).toBe(`${tenantA}/exports/${jobId}.zip`);
168
+ expect(row?.expiresAt).not.toBeNull();
169
+ expect(row?.bytesWritten).toBeGreaterThan(0);
170
+
171
+ // ZIP wirklich im Storage
172
+ const provider = await buildProvider(tenantA);
173
+ expect(await provider.exists(`${tenantA}/exports/${jobId}.zip`)).toBe(true);
174
+ });
175
+
176
+ test("ZIP ist real entpackbar via Info-ZIP + enthaelt bundle.json", async () => {
177
+ const jobId = await seedPendingJob();
178
+
179
+ await runExportJobs({
180
+ db: stack.db,
181
+ registry: stack.registry,
182
+ buildStorageProvider: buildProvider,
183
+ now: NOW(),
184
+ });
185
+
186
+ const provider = await buildProvider(tenantA);
187
+ const zipBytes = await provider.read(`${tenantA}/exports/${jobId}.zip`);
188
+
189
+ // Real-Decoder-Roundtrip via Info-ZIP unzip
190
+ const dir = await mkdtemp(join(tmpdir(), "kumiko-worker-test-"));
191
+ try {
192
+ const zipPath = join(dir, "out.zip");
193
+ await writeFile(zipPath, zipBytes);
194
+ await new Promise<void>((resolve, reject) => {
195
+ const proc = spawn("unzip", ["-d", join(dir, "out"), zipPath]);
196
+ proc.on("close", (code) =>
197
+ code === 0 ? resolve() : reject(new Error(`unzip exit ${code}`)),
198
+ );
199
+ proc.on("error", reject);
200
+ });
201
+ const bundleJson = await readFile(join(dir, "out", "bundle.json"), "utf8");
202
+ const bundle = JSON.parse(bundleJson);
203
+ expect(bundle.userId).toBe(aliceUser.id);
204
+ expect(bundle.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
205
+ } finally {
206
+ await rm(dir, { recursive: true, force: true });
207
+ }
208
+ });
209
+ });
210
+
211
+ describe("runExportJobs :: stale-detection", () => {
212
+ test("running-Job mit startedAt > exportStaleTimeoutMinutes → failed", async () => {
213
+ // Seed via echten Pfad damit event-stream existiert; dann direct-
214
+ // update auf running mit alter startedAt (Test-Exemption vom Guard).
215
+ const jobId = await seedPendingJob();
216
+ const T = getTemporal();
217
+ const twoHoursAgo = T.Instant.fromEpochMilliseconds(Date.now() - 2 * 60 * 60 * 1000);
218
+ await stack.db
219
+ .update(exportJobsTable)
220
+ .set({ status: EXPORT_JOB_STATUS.Running, startedAt: twoHoursAgo })
221
+ .where(sql`id = ${jobId}`);
222
+
223
+ const result = await runExportJobs({
224
+ db: stack.db,
225
+ registry: stack.registry,
226
+ buildStorageProvider: buildProvider,
227
+ now: NOW(),
228
+ });
229
+
230
+ expect(result.staleFailedJobIds).toContain(jobId);
231
+
232
+ const [row] = (await stack.db
233
+ .select()
234
+ .from(exportJobsTable)
235
+ .where(sql`id = ${jobId}`)) as Array<{
236
+ status: string;
237
+ errorMessage: string | null;
238
+ }>;
239
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Failed);
240
+ expect(row?.errorMessage).toMatch(/stale: worker crashed/i);
241
+ });
242
+
243
+ test("running-Job mit startedAt UNTER stale-Cutoff bleibt running", async () => {
244
+ const jobId = await seedPendingJob();
245
+ const T = getTemporal();
246
+ const justNow = T.Instant.fromEpochMilliseconds(Date.now() - 60 * 1000); // 1min ago
247
+ await stack.db
248
+ .update(exportJobsTable)
249
+ .set({ status: EXPORT_JOB_STATUS.Running, startedAt: justNow })
250
+ .where(sql`id = ${jobId}`);
251
+
252
+ const result = await runExportJobs({
253
+ db: stack.db,
254
+ registry: stack.registry,
255
+ buildStorageProvider: buildProvider,
256
+ now: NOW(),
257
+ });
258
+
259
+ expect(result.staleFailedJobIds).not.toContain(jobId);
260
+ const [row] = (await stack.db
261
+ .select()
262
+ .from(exportJobsTable)
263
+ .where(sql`id = ${jobId}`)) as Array<{ status: string }>;
264
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Running);
265
+ });
266
+
267
+ test("stale-Job mit downloadStorageKey (real-Pfad nach 4a.fix path-pre-claim) → ZIP cleanup im selben Pass", async () => {
268
+ // Real-prod-Pfad-Pin (Atom 4a.fix2): nach path-pre-claim hat ein
269
+ // running-Job downloadStorageKey gesetzt. Wenn Stale-Detection den
270
+ // Job auf failed flippt, sollte storageCleanupPass im selben Worker-
271
+ // Pass den orphan-ZIP entfernen.
272
+ const jobId = await seedPendingJob();
273
+ const T = getTemporal();
274
+ const twoHoursAgo = T.Instant.fromEpochMilliseconds(Date.now() - 2 * 60 * 60 * 1000);
275
+ const storageKey = `${tenantA}/exports/${jobId}.zip`;
276
+
277
+ // Simuliert real-Pfad: claim-update hatte status=running + storageKey
278
+ // gesetzt + ZIP geschrieben. Worker dann gecrashed (kein done-flip).
279
+ const provider = await buildProvider(tenantA);
280
+ await provider.write(storageKey, new Uint8Array([1, 2, 3]));
281
+ await stack.db
282
+ .update(exportJobsTable)
283
+ .set({
284
+ status: EXPORT_JOB_STATUS.Running,
285
+ startedAt: twoHoursAgo,
286
+ downloadStorageKey: storageKey,
287
+ })
288
+ .where(sql`id = ${jobId}`);
289
+
290
+ const result = await runExportJobs({
291
+ db: stack.db,
292
+ registry: stack.registry,
293
+ buildStorageProvider: buildProvider,
294
+ now: NOW(),
295
+ });
296
+
297
+ expect(result.staleFailedJobIds).toContain(jobId);
298
+ expect(result.cleanedJobIds).toContain(jobId);
299
+ expect(await provider.exists(storageKey)).toBe(false);
300
+
301
+ const [row] = (await stack.db
302
+ .select()
303
+ .from(exportJobsTable)
304
+ .where(sql`id = ${jobId}`)) as Array<{ status: string; downloadStorageKey: string | null }>;
305
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Failed);
306
+ expect(row?.downloadStorageKey).toBeNull();
307
+ });
308
+ });
309
+
310
+ describe("runExportJobs :: storage-cleanup", () => {
311
+ test("done-Job mit expiresAt+grace in Vergangenheit → downloadStorageKey null + Datei geloescht", async () => {
312
+ const jobId = await seedPendingJob();
313
+ const T = getTemporal();
314
+ const longAgo = T.Instant.fromEpochMilliseconds(
315
+ Date.now() - 365 * 24 * 60 * 60 * 1000, // 1 Jahr ago
316
+ );
317
+ const storageKey = `${tenantA}/exports/${jobId}.zip`;
318
+ const provider = await buildProvider(tenantA);
319
+ await provider.write(storageKey, new Uint8Array([1, 2, 3]));
320
+
321
+ await stack.db
322
+ .update(exportJobsTable)
323
+ .set({
324
+ status: EXPORT_JOB_STATUS.Done,
325
+ startedAt: longAgo,
326
+ completedAt: longAgo,
327
+ downloadStorageKey: storageKey,
328
+ expiresAt: longAgo,
329
+ })
330
+ .where(sql`id = ${jobId}`);
331
+
332
+ const result = await runExportJobs({
333
+ db: stack.db,
334
+ registry: stack.registry,
335
+ buildStorageProvider: buildProvider,
336
+ now: NOW(),
337
+ });
338
+
339
+ expect(result.cleanedJobIds).toContain(jobId);
340
+ expect(await provider.exists(storageKey)).toBe(false);
341
+
342
+ const [row] = (await stack.db
343
+ .select()
344
+ .from(exportJobsTable)
345
+ .where(sql`id = ${jobId}`)) as Array<{
346
+ status: string;
347
+ downloadStorageKey: string | null;
348
+ }>;
349
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
350
+ expect(row?.downloadStorageKey).toBeNull();
351
+ });
352
+
353
+ test("done-Job mit frisch-expiresAt (innerhalb grace) bleibt — Pufferzone", async () => {
354
+ const jobId = await seedPendingJob();
355
+ const T = getTemporal();
356
+ const oneHourAgo = T.Instant.fromEpochMilliseconds(Date.now() - 60 * 60 * 1000);
357
+ const storageKey = `${tenantA}/exports/${jobId}.zip`;
358
+ const provider = await buildProvider(tenantA);
359
+ await provider.write(storageKey, new Uint8Array([4, 5, 6]));
360
+
361
+ await stack.db
362
+ .update(exportJobsTable)
363
+ .set({
364
+ status: EXPORT_JOB_STATUS.Done,
365
+ startedAt: oneHourAgo,
366
+ completedAt: oneHourAgo,
367
+ downloadStorageKey: storageKey,
368
+ expiresAt: oneHourAgo,
369
+ })
370
+ .where(sql`id = ${jobId}`);
371
+
372
+ const result = await runExportJobs({
373
+ db: stack.db,
374
+ registry: stack.registry,
375
+ buildStorageProvider: buildProvider,
376
+ now: NOW(),
377
+ });
378
+
379
+ expect(result.cleanedJobIds).not.toContain(jobId);
380
+ expect(await provider.exists(storageKey)).toBe(true);
381
+ });
382
+ });
383
+
384
+ describe("runExportJobs :: idempotency", () => {
385
+ test("zweiter Run nach done ist no-op fuer den selben Job", async () => {
386
+ const jobId = await seedPendingJob();
387
+
388
+ const first = await runExportJobs({
389
+ db: stack.db,
390
+ registry: stack.registry,
391
+ buildStorageProvider: buildProvider,
392
+ now: NOW(),
393
+ });
394
+ expect(first.completedJobIds).toContain(jobId);
395
+
396
+ const second = await runExportJobs({
397
+ db: stack.db,
398
+ registry: stack.registry,
399
+ buildStorageProvider: buildProvider,
400
+ now: NOW(),
401
+ });
402
+ // 2nd Run findet keine pending Jobs mehr (status=done)
403
+ expect(second.completedJobIds).toEqual([]);
404
+ expect(second.failedJobIds).toEqual([]);
405
+ });
406
+ });
407
+
408
+ describe("runExportJobs :: concurrency", () => {
409
+ test("zwei parallele Runner mit 1 pending-Job → genau ein done, ein skipped", async () => {
410
+ // Pinst die zentrale Korrektheits-Behauptung von Atom 3b: optimistic-
411
+ // locking via crud.update mit version-conflict gewinnt EXAKT ein
412
+ // Worker-Replica den claim, der zweite skipped silent. Vor dem Fix
413
+ // war der "skipped"-Pfad totes coverage.
414
+ const jobId = await seedPendingJob();
415
+
416
+ const [resultA, resultB] = await Promise.all([
417
+ runExportJobs({
418
+ db: stack.db,
419
+ registry: stack.registry,
420
+ buildStorageProvider: buildProvider,
421
+ now: NOW(),
422
+ }),
423
+ runExportJobs({
424
+ db: stack.db,
425
+ registry: stack.registry,
426
+ buildStorageProvider: buildProvider,
427
+ now: NOW(),
428
+ }),
429
+ ]);
430
+
431
+ // Genau ein Runner hat den Job als completed verbucht
432
+ const completedAcrossBoth = [...resultA.completedJobIds, ...resultB.completedJobIds];
433
+ expect(completedAcrossBoth.filter((id) => id === jobId)).toHaveLength(1);
434
+
435
+ // Keine doppelten failures
436
+ expect([...resultA.failedJobIds, ...resultB.failedJobIds]).toEqual([]);
437
+
438
+ // DB hat genau eine done-Row
439
+ const rows = (await stack.db
440
+ .select()
441
+ .from(exportJobsTable)
442
+ .where(sql`id = ${jobId}`)) as Array<{ status: string; downloadStorageKey: string | null }>;
443
+ expect(rows).toHaveLength(1);
444
+ expect(rows[0]?.status).toBe(EXPORT_JOB_STATUS.Done);
445
+ expect(rows[0]?.downloadStorageKey).toBe(`${tenantA}/exports/${jobId}.zip`);
446
+
447
+ // Storage hat genau ein ZIP — kein Race-induziertes Doppel-Schreiben
448
+ const provider = await buildProvider(tenantA);
449
+ expect(await provider.exists(`${tenantA}/exports/${jobId}.zip`)).toBe(true);
450
+ });
451
+ });
452
+
453
+ describe("runExportJobs :: stale-detection profile-driven cutoff", () => {
454
+ test("running-Job 45min ago + Profile-Default 30min → wird gefailed (kein 60min-coarse-filter mehr)", async () => {
455
+ // Regression-Pin: vor 3b.fix hatte staleDetectionPass einen
456
+ // hardcoded `startedAt <= now-1h` Coarse-Filter. Default-Profile
457
+ // exportStaleTimeoutMinutes = 30, also waeren 30-60min-alte
458
+ // Stale-Jobs nicht erkannt worden. Mit Filter raus + per-Job
459
+ // profile-resolve im Loop wird das jetzt korrekt gefangen.
460
+ const jobId = await seedPendingJob();
461
+ const T = getTemporal();
462
+ const fortyFiveMinAgo = T.Instant.fromEpochMilliseconds(Date.now() - 45 * 60 * 1000);
463
+ await stack.db
464
+ .update(exportJobsTable)
465
+ .set({ status: EXPORT_JOB_STATUS.Running, startedAt: fortyFiveMinAgo })
466
+ .where(sql`id = ${jobId}`);
467
+
468
+ const result = await runExportJobs({
469
+ db: stack.db,
470
+ registry: stack.registry,
471
+ buildStorageProvider: buildProvider,
472
+ now: NOW(),
473
+ });
474
+
475
+ expect(result.staleFailedJobIds).toContain(jobId);
476
+ const [row] = (await stack.db
477
+ .select()
478
+ .from(exportJobsTable)
479
+ .where(sql`id = ${jobId}`)) as Array<{ status: string }>;
480
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Failed);
481
+ });
482
+ });
483
+
484
+ describe("runExportJobs :: Atom 4a download-tokens", () => {
485
+ test("Worker generiert Token + Hash in DB nach done-flip", async () => {
486
+ const jobId = await seedPendingJob();
487
+
488
+ const result = await runExportJobs({
489
+ db: stack.db,
490
+ registry: stack.registry,
491
+ buildStorageProvider: buildProvider,
492
+ now: NOW(),
493
+ });
494
+
495
+ expect(result.completedJobIds).toContain(jobId);
496
+
497
+ // Plain-Token im Result fuer Atom 5
498
+ const plainToken = result.tokenByJobId.get(jobId);
499
+ expect(plainToken).toBeDefined();
500
+ expect(plainToken).toMatch(/^[A-Za-z0-9_-]{43}$/);
501
+
502
+ // Hash in DB matched plain via hashDownloadToken roundtrip
503
+ const tokenRows = (await stack.db
504
+ .select()
505
+ .from(exportDownloadTokensTable)
506
+ .where(sql`job_id = ${jobId}`)) as Array<{
507
+ jobId: string;
508
+ tokenHash: string;
509
+ issuedAt: { toString(): string };
510
+ expiresAt: { toString(): string };
511
+ lastUsedAt: { toString(): string } | null;
512
+ useCount: number | null;
513
+ }>;
514
+ expect(tokenRows).toHaveLength(1);
515
+ expect(tokenRows[0]?.tokenHash).toBe(await hashDownloadToken(plainToken as string));
516
+ expect(tokenRows[0]?.lastUsedAt).toBeNull();
517
+ // Atom 4a.fix: useCount explicit 0 statt null — 4b's Increment ist
518
+ // dadurch trivial (useCount + 1 ohne COALESCE).
519
+ expect(tokenRows[0]?.useCount).toBe(0);
520
+
521
+ // expiresAt im Token = job.expiresAt (denormalized)
522
+ const [jobRow] = (await stack.db
523
+ .select()
524
+ .from(exportJobsTable)
525
+ .where(sql`id = ${jobId}`)) as Array<{ expiresAt: { toString(): string } | null }>;
526
+ expect(tokenRows[0]?.expiresAt.toString()).toBe(jobRow?.expiresAt?.toString());
527
+ });
528
+
529
+ test("ES via crud.create — Token-Created-Event in kumiko_events", async () => {
530
+ // Pinst dass Worker den Token via Event-Sourcing erstellt, nicht
531
+ // direct-INSERT (Memory `feedback_no_fake_dispatcher`). Ohne Event
532
+ // koennten Atom-5-Notification-Hooks nicht aufs Token-Created-Event
533
+ // hooken.
534
+ await seedPendingJob();
535
+ await runExportJobs({
536
+ db: stack.db,
537
+ registry: stack.registry,
538
+ buildStorageProvider: buildProvider,
539
+ now: NOW(),
540
+ });
541
+
542
+ const events = (await stack.db.execute(
543
+ sql`SELECT type FROM kumiko_events WHERE type LIKE 'export-download-token.%'`,
544
+ )) as unknown as Array<{ type: string }>;
545
+ // Mindestens 1 created-Event fuer den Token
546
+ expect(events.some((e) => e.type === "export-download-token.created")).toBe(true);
547
+ });
548
+
549
+ test("Worker idempotency: 2× run done-Job → kein 2. Token (UNIQUE jobId)", async () => {
550
+ const jobId = await seedPendingJob();
551
+ await runExportJobs({
552
+ db: stack.db,
553
+ registry: stack.registry,
554
+ buildStorageProvider: buildProvider,
555
+ now: NOW(),
556
+ });
557
+
558
+ // 2nd run: pending-Loop ist leer (status=done), kein 2. Token-Insert
559
+ const second = await runExportJobs({
560
+ db: stack.db,
561
+ registry: stack.registry,
562
+ buildStorageProvider: buildProvider,
563
+ now: NOW(),
564
+ });
565
+ expect(second.completedJobIds).toEqual([]);
566
+ expect(second.tokenByJobId.size).toBe(0);
567
+
568
+ const tokenRows = (await stack.db
569
+ .select()
570
+ .from(exportDownloadTokensTable)
571
+ .where(sql`job_id = ${jobId}`)) as Array<unknown>;
572
+ expect(tokenRows).toHaveLength(1);
573
+ });
574
+
575
+ test("failed-Job: kein Token wird generiert", async () => {
576
+ // Bewusst keinen Job — leerer Pending-Pass. Aber wir koennen den
577
+ // failure-Pfad pinnen via runUserExport-Fehler. Einfacher: nur
578
+ // verifizieren dass tokenByJobId leer bleibt wenn keine completed-Jobs.
579
+ const result = await runExportJobs({
580
+ db: stack.db,
581
+ registry: stack.registry,
582
+ buildStorageProvider: buildProvider,
583
+ now: NOW(),
584
+ });
585
+ expect(result.completedJobIds).toEqual([]);
586
+ expect(result.tokenByJobId.size).toBe(0);
587
+
588
+ const allTokens = (await stack.db.select().from(exportDownloadTokensTable)) as Array<unknown>;
589
+ expect(allTokens).toHaveLength(0);
590
+ });
591
+
592
+ test("Token-create UNIQUE-violation → Job=failed (NICHT done) — Sequencing-Pin", async () => {
593
+ // Atom 4a.fix Sequence-Garantie: Token wird VOR done-flip erstellt.
594
+ // Wenn Token-create failt (z.B. UNIQUE-violation auf jobId), faellt
595
+ // der Worker in catch-Pfad → Job auf failed (NICHT done). Vorher
596
+ // war die Reihenfolge gefaehrlich: Job=done dann Token-create-fail
597
+ // → catch flippt done→failed (nicht-monoton + verwirrend).
598
+ //
599
+ // Setup: pending Job seeden, dann eine duplicate-Token-Row mit
600
+ // demselben jobId direct-INSERT (test-fixture). Worker pickt Job,
601
+ // schreibt ZIP, versucht Token-create → UNIQUE hits → throw.
602
+ const jobId = await seedPendingJob();
603
+
604
+ // Force UNIQUE-violation: pre-seed eine Token-Row mit jobId via
605
+ // direct-INSERT (Test-Exemption). Worker's tokenCrud.create wird
606
+ // dann mit constraintName "read_export_download_tokens_one_per_job"
607
+ // failen.
608
+ await stack.db.execute(sql`
609
+ INSERT INTO read_export_download_tokens
610
+ (id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
611
+ VALUES (
612
+ gen_random_uuid(),
613
+ ${tenantA},
614
+ ${jobId},
615
+ ${"existing-hash"},
616
+ now(),
617
+ now() + interval '7 days',
618
+ 1,
619
+ now(),
620
+ now()
621
+ )
622
+ `);
623
+
624
+ const result = await runExportJobs({
625
+ db: stack.db,
626
+ registry: stack.registry,
627
+ buildStorageProvider: buildProvider,
628
+ now: NOW(),
629
+ });
630
+
631
+ // Job ist failed, NICHT done — Sequence-Garantie
632
+ expect(result.completedJobIds).not.toContain(jobId);
633
+ expect(result.failedJobIds).toContain(jobId);
634
+
635
+ const [row] = (await stack.db
636
+ .select()
637
+ .from(exportJobsTable)
638
+ .where(sql`id = ${jobId}`)) as Array<{ status: string; errorMessage: string | null }>;
639
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Failed);
640
+ expect(row?.errorMessage).toMatch(/Token-Creation failed/);
641
+ });
642
+
643
+ test("failed-Job mit downloadStorageKey → ZIP wird im Cleanup-Pass entfernt (orphan-fix)", async () => {
644
+ // Atom 4a.fix orphan-cleanup: failed-Jobs haben downloadStorageKey
645
+ // gesetzt (path-pre-claim) — der ZIP-Pfad ist persistiert ab claim.
646
+ // Storage-Cleanup-Pass fuer failed-Jobs: sofort ZIP loeschen (kein
647
+ // Grace), DB-Spalte nullen.
648
+ const jobId = await seedPendingJob();
649
+ const storageKey = `${tenantA}/exports/${jobId}.zip`;
650
+
651
+ // ZIP in storage seeden + Job manuell auf failed mit storageKey
652
+ // (simuliert orphan-state nach Worker-crash).
653
+ const provider = await buildProvider(tenantA);
654
+ await provider.write(storageKey, new Uint8Array([99, 99, 99]));
655
+ await stack.db
656
+ .update(exportJobsTable)
657
+ .set({
658
+ status: EXPORT_JOB_STATUS.Failed,
659
+ downloadStorageKey: storageKey,
660
+ errorMessage: "synthetic crash mid-run",
661
+ })
662
+ .where(sql`id = ${jobId}`);
663
+
664
+ const result = await runExportJobs({
665
+ db: stack.db,
666
+ registry: stack.registry,
667
+ buildStorageProvider: buildProvider,
668
+ now: NOW(),
669
+ });
670
+
671
+ expect(result.cleanedJobIds).toContain(jobId);
672
+ expect(await provider.exists(storageKey)).toBe(false);
673
+
674
+ const [row] = (await stack.db
675
+ .select()
676
+ .from(exportJobsTable)
677
+ .where(sql`id = ${jobId}`)) as Array<{ downloadStorageKey: string | null }>;
678
+ expect(row?.downloadStorageKey).toBeNull();
679
+ });
680
+ });
681
+
682
+ describe("runExportJobs :: Atom 3c file-binaries", () => {
683
+ // Helper: spy auf buildProvider damit wir verifizieren koennen, dass
684
+ // multi-tenant-fileRefs ZWEI separate Provider-Builds triggern (Cache-
685
+ // Invariant: pro tenant nur einmal).
686
+ let providerCallsByTenant: Map<string, number>;
687
+
688
+ function spiedBuildProvider(tenantId: string): Promise<FileStorageProvider> {
689
+ providerCallsByTenant.set(tenantId, (providerCallsByTenant.get(tenantId) ?? 0) + 1);
690
+ return buildProvider(tenantId);
691
+ }
692
+
693
+ // Pre-seed eine fileRef-Row in read_user_files damit der user-Hook in
694
+ // user-data-rights-defaults sie findet. Aber: wir mounten diesen
695
+ // defaults-Feature NICHT, weil dann file-foundation + files-Feature
696
+ // mit dazukommen muessen. Stattdessen: ein test-only Feature das einen
697
+ // userData-export-Hook mit fileRefs[] direkt zurueckgibt.
698
+ function seedFileBytes(tenantId: string, storageKey: string, bytes: Uint8Array) {
699
+ return buildProvider(tenantId).then((p) => p.write(storageKey, bytes));
700
+ }
701
+
702
+ beforeEach(() => {
703
+ providerCallsByTenant = new Map();
704
+ });
705
+
706
+ // Stack mit Test-Feature das fileRefs liefert. Ueberschreibt das outer
707
+ // stack via local setupTestStack — nur fuer diesen describe-Block.
708
+ let localStack: TestStack;
709
+ // Module-level mutable damit der malicious-filename-Test den User-Input
710
+ // pro Test variieren kann ohne neuen Stack aufzubauen. Default sicher.
711
+ let currentTestFileName = "report.pdf";
712
+ beforeAll(async () => {
713
+ const { defineFeature, EXT_USER_DATA } = await import("@cosmicdrift/kumiko-framework/engine");
714
+ const testFileExporter = defineFeature("test-file-exporter", (r) => {
715
+ r.useExtension(EXT_USER_DATA, "test-file", {
716
+ export: async (ctx: { tenantId: string; userId: string }) => ({
717
+ entity: "test-file",
718
+ rows: [{ id: "f1", name: currentTestFileName }],
719
+ fileRefs: [
720
+ {
721
+ fileRefId: "f1",
722
+ storageKey: `${ctx.tenantId}/test-file/f1.pdf`,
723
+ fileName: currentTestFileName,
724
+ },
725
+ ],
726
+ }),
727
+ });
728
+ });
729
+
730
+ localStack = await setupTestStack({
731
+ features: [
732
+ createUserFeature(),
733
+ createDataRetentionFeature(),
734
+ createComplianceProfilesFeature(),
735
+ createUserDataRightsFeature(),
736
+ testFileExporter,
737
+ ],
738
+ });
739
+ await unsafeCreateEntityTable(localStack.db, exportJobEntity);
740
+ await unsafeCreateEntityTable(localStack.db, exportDownloadTokenEntity);
741
+ await unsafeCreateEntityTable(localStack.db, tenantComplianceProfileEntity);
742
+ await createEventsTable(localStack.db);
743
+ await localStack.db.execute(sql`
744
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
745
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
746
+ tenant_id UUID NOT NULL,
747
+ user_id TEXT NOT NULL,
748
+ version INTEGER NOT NULL DEFAULT 0,
749
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
750
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
751
+ inserted_by_id TEXT,
752
+ modified_by_id TEXT,
753
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
754
+ deleted_at TIMESTAMPTZ,
755
+ deleted_by_id TEXT,
756
+ roles TEXT NOT NULL DEFAULT '[]',
757
+ UNIQUE(user_id, tenant_id)
758
+ )
759
+ `);
760
+ });
761
+
762
+ afterAll(async () => {
763
+ if (localStack) await localStack.cleanup();
764
+ });
765
+
766
+ beforeEach(async () => {
767
+ if (!localStack) return;
768
+ await localStack.db.delete(exportDownloadTokensTable);
769
+ await localStack.db.delete(exportJobsTable);
770
+ await localStack.db.execute(sql`DELETE FROM kumiko_events`);
771
+ await localStack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
772
+ await localStack.db.execute(sql`DELETE FROM read_tenant_memberships`);
773
+ // Reset zu safe Default damit kein Test den State an den naechsten leakt.
774
+ currentTestFileName = "report.pdf";
775
+ });
776
+
777
+ async function seedMembership(tenantId: string, userId: string | number) {
778
+ await localStack.db.execute(sql`
779
+ INSERT INTO read_tenant_memberships (tenant_id, user_id)
780
+ VALUES (${tenantId}, ${String(userId)})
781
+ ON CONFLICT (user_id, tenant_id) DO NOTHING
782
+ `);
783
+ }
784
+
785
+ async function seedPendingJobLocal(user: typeof aliceUser): Promise<string> {
786
+ const result = await localStack.http.writeOk<{ jobId: string }>(
787
+ "user-data-rights:write:request-export",
788
+ {},
789
+ user,
790
+ );
791
+ return result.jobId;
792
+ }
793
+
794
+ test("happy: 1 fileRef in 1 Tenant → ZIP enthaelt file-bytes unter zipPath", async () => {
795
+ await seedMembership(tenantA, aliceUser.id);
796
+ await seedFileBytes(tenantA, `${tenantA}/test-file/f1.pdf`, new Uint8Array([1, 2, 3, 4, 5]));
797
+
798
+ const jobId = await seedPendingJobLocal(aliceUser);
799
+
800
+ const result = await runExportJobs({
801
+ db: localStack.db,
802
+ registry: localStack.registry,
803
+ buildStorageProvider: spiedBuildProvider,
804
+ now: NOW(),
805
+ });
806
+
807
+ expect(result.completedJobIds).toContain(jobId);
808
+ expect(result.errors).toEqual([]);
809
+
810
+ // ZIP entpacken + file-bytes verifizieren
811
+ const provider = await buildProvider(tenantA);
812
+ const zipBytes = await provider.read(`${tenantA}/exports/${jobId}.zip`);
813
+ const dir = await mkdtemp(join(tmpdir(), "kumiko-3c-test-"));
814
+ try {
815
+ const zipPath = join(dir, "out.zip");
816
+ await writeFile(zipPath, zipBytes);
817
+ await new Promise<void>((resolve, reject) => {
818
+ const proc = spawn("unzip", ["-d", join(dir, "out"), zipPath]);
819
+ proc.on("close", (code) =>
820
+ code === 0 ? resolve() : reject(new Error(`unzip exit ${code}`)),
821
+ );
822
+ proc.on("error", reject);
823
+ });
824
+
825
+ // bundle.json existiert + hat zipPath
826
+ const bundle = JSON.parse(await readFile(join(dir, "out", "bundle.json"), "utf8"));
827
+ expect(bundle.fileRefs).toHaveLength(1);
828
+ const expectedZipPath = `files/${tenantA}/f1-report.pdf`;
829
+ expect(bundle.fileRefs[0].zipPath).toBe(expectedZipPath);
830
+
831
+ // File-bytes liegen unter genau diesem Pfad
832
+ const fileBytes = await readFile(join(dir, "out", expectedZipPath));
833
+ expect(Array.from(fileBytes)).toEqual([1, 2, 3, 4, 5]);
834
+ } finally {
835
+ await rm(dir, { recursive: true, force: true });
836
+ }
837
+ });
838
+
839
+ test("multi-tenant: fileRefs aus 2 Tenants → 2 separate Provider-Builds (cache-invariant)", async () => {
840
+ const tenantB = testTenantId(2);
841
+ await seedMembership(tenantA, aliceUser.id);
842
+ await seedMembership(tenantB, aliceUser.id);
843
+ await seedFileBytes(tenantA, `${tenantA}/test-file/f1.pdf`, new Uint8Array([10]));
844
+ await seedFileBytes(tenantB, `${tenantB}/test-file/f1.pdf`, new Uint8Array([20]));
845
+
846
+ const jobId = await seedPendingJobLocal(aliceUser);
847
+
848
+ const result = await runExportJobs({
849
+ db: localStack.db,
850
+ registry: localStack.registry,
851
+ buildStorageProvider: spiedBuildProvider,
852
+ now: NOW(),
853
+ });
854
+
855
+ expect(result.completedJobIds).toContain(jobId);
856
+
857
+ // Cache-Invariant: Tenant A Provider wurde 1x gebaut (Job-Tenant fuer
858
+ // writeStream + 1. fileRef-Read), Tenant B nur fuer den 2. fileRef-Read.
859
+ // Mehrere fileRef-Reads pro Tenant duerfen aber NICHT mehrere builds
860
+ // triggern.
861
+ expect(providerCallsByTenant.get(tenantA)).toBe(1);
862
+ expect(providerCallsByTenant.get(tenantB)).toBe(1);
863
+ });
864
+
865
+ test("missing-file: storage-key gibt's nicht → job=failed mit klarem error", async () => {
866
+ await seedMembership(tenantA, aliceUser.id);
867
+ // Bewusst KEIN seedFileBytes — der storage-key existiert nicht im
868
+ // in-memory-provider; readStream throw't beim ersten chunk-pull.
869
+ const jobId = await seedPendingJobLocal(aliceUser);
870
+
871
+ const result = await runExportJobs({
872
+ db: localStack.db,
873
+ registry: localStack.registry,
874
+ buildStorageProvider: spiedBuildProvider,
875
+ now: NOW(),
876
+ });
877
+
878
+ expect(result.completedJobIds).not.toContain(jobId);
879
+ expect(result.failedJobIds).toContain(jobId);
880
+ expect(result.errors[0]?.message).toMatch(/in-memory file not found/);
881
+
882
+ const [row] = (await localStack.db
883
+ .select()
884
+ .from(exportJobsTable)
885
+ .where(sql`id = ${jobId}`)) as Array<{ status: string; errorMessage: string | null }>;
886
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Failed);
887
+ expect(row?.errorMessage).toMatch(/in-memory file not found/);
888
+ });
889
+
890
+ test("malicious filename: '../../etc/passwd' bleibt im ZIP-Root (defense-in-depth e2e)", async () => {
891
+ // Defense-in-depth: pinst die ganze Chain
892
+ // user-input fileName → buildFileRefZipPath → bundle.fileRefs[].zipPath
893
+ // → bundleToZipEntries → ZipEntry.path → unzip.
894
+ // Ein User-uploaded-fileName mit "../../etc/passwd" darf NIEMALS
895
+ // ein ZIP-Reader dazu bringen, ausserhalb des extract-Roots zu
896
+ // schreiben. Das wird unit-getestet auf zip-path-Ebene; der
897
+ // Integration-Test hier pinst dass der Sanitize-Pfad WIRKLICH
898
+ // durchgaengig wirkt.
899
+ currentTestFileName = "../../etc/passwd";
900
+ await seedMembership(tenantA, aliceUser.id);
901
+ await seedFileBytes(
902
+ tenantA,
903
+ `${tenantA}/test-file/f1.pdf`,
904
+ new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
905
+ );
906
+
907
+ const jobId = await seedPendingJobLocal(aliceUser);
908
+ const result = await runExportJobs({
909
+ db: localStack.db,
910
+ registry: localStack.registry,
911
+ buildStorageProvider: spiedBuildProvider,
912
+ now: NOW(),
913
+ });
914
+ expect(result.completedJobIds).toContain(jobId);
915
+
916
+ const provider = await buildProvider(tenantA);
917
+ const zipBytes = await provider.read(`${tenantA}/exports/${jobId}.zip`);
918
+ const dir = await mkdtemp(join(tmpdir(), "kumiko-3c-malicious-"));
919
+ try {
920
+ const zipPath = join(dir, "out.zip");
921
+ const extractRoot = join(dir, "out");
922
+ await writeFile(zipPath, zipBytes);
923
+ await new Promise<void>((resolve, reject) => {
924
+ const proc = spawn("unzip", ["-d", extractRoot, zipPath]);
925
+ proc.on("close", (code) =>
926
+ code === 0 ? resolve() : reject(new Error(`unzip exit ${code}`)),
927
+ );
928
+ proc.on("error", reject);
929
+ });
930
+
931
+ // Bundle hat zipPath ohne ".." gespeichert
932
+ const bundle = JSON.parse(await readFile(join(extractRoot, "bundle.json"), "utf8"));
933
+ expect(bundle.fileRefs[0].zipPath).not.toContain("..");
934
+ expect(bundle.fileRefs[0].zipPath).toMatch(/^files\//);
935
+
936
+ // ZIP-Entry-Pfad ist DERSELBE wie zipPath in bundle.json
937
+ // → Reader hat KEINE Datei outside extractRoot geschrieben.
938
+ // unzip wuerde mit warning skipen wenn der path-traversal greifen
939
+ // wuerde; wir verifizieren via filesystem-check ausserhalb.
940
+ const dirAbove = join(dir, "..");
941
+ const escapedFile = join(dirAbove, "etc", "passwd");
942
+ const { access } = await import("node:fs/promises");
943
+ await expect(access(escapedFile)).rejects.toThrow();
944
+
945
+ // Die Bytes sind unter dem sanitized-Path im ZIP-Root.
946
+ const fileBytes = await readFile(join(extractRoot, bundle.fileRefs[0].zipPath));
947
+ expect(Array.from(fileBytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
948
+ } finally {
949
+ await rm(dir, { recursive: true, force: true });
950
+ }
951
+ });
952
+ });
953
+
954
+ describe("runExportJobs :: Atom 5 notification-callbacks", () => {
955
+ test("done-Job: sendExportReadyEmail wird mit downloadUrl + userEmail gerufen", async () => {
956
+ const jobId = await seedPendingJob();
957
+ type SentArgs = {
958
+ userId: string;
959
+ userEmail: string;
960
+ jobId: string;
961
+ downloadUrl: string;
962
+ expiresAt: string;
963
+ bytesWritten: number | null;
964
+ tenantId: string;
965
+ };
966
+ const sentEmails: SentArgs[] = [];
967
+ const exportReadyMock = async (args: SentArgs) => {
968
+ sentEmails.push(args);
969
+ };
970
+
971
+ const result = await runExportJobs({
972
+ db: stack.db,
973
+ registry: stack.registry,
974
+ buildStorageProvider: buildProvider,
975
+ now: NOW(),
976
+ sendExportReadyEmail: exportReadyMock,
977
+ appExportDownloadUrl: "https://app.example.com/user-export/by-token",
978
+ });
979
+
980
+ expect(result.completedJobIds).toContain(jobId);
981
+ expect(sentEmails).toHaveLength(1);
982
+ const sent = sentEmails[0];
983
+ if (!sent) throw new Error("expected 1 sent email");
984
+ expect(sent.userId).toBe(String(aliceUser.id));
985
+ expect(sent.userEmail).toBe("alice@example.com");
986
+ expect(sent.jobId).toBe(jobId);
987
+ expect(sent.downloadUrl).toMatch(
988
+ /^https:\/\/app\.example\.com\/user-export\/by-token\?token=[A-Za-z0-9_%-]+$/,
989
+ );
990
+ // plain-token aus dem callback-arg entspricht dem result.tokenByJobId
991
+ const plainFromResult = result.tokenByJobId.get(jobId);
992
+ expect(plainFromResult).toBeDefined();
993
+ expect(sent.downloadUrl).toContain(encodeURIComponent(plainFromResult ?? ""));
994
+ });
995
+
996
+ test("done-Job ohne Callback: kein Email, Worker-Run succeeded", async () => {
997
+ const jobId = await seedPendingJob();
998
+ const result = await runExportJobs({
999
+ db: stack.db,
1000
+ registry: stack.registry,
1001
+ buildStorageProvider: buildProvider,
1002
+ now: NOW(),
1003
+ // Kein sendExportReadyEmail/appExportDownloadUrl
1004
+ });
1005
+ expect(result.completedJobIds).toContain(jobId);
1006
+ // Kein Email, kein Throw — Worker laeuft normal durch
1007
+ });
1008
+
1009
+ test("done-Job mit Callback aber ohne appExportDownloadUrl → throw bubbelt zum r.job", async () => {
1010
+ // Boot-Misconfig-Detection: wer Callback setzt aber URL vergisst
1011
+ // soll einen klaren Error sehen. Worker wirft direkt — r.job-Wrap
1012
+ // markiert Worker-Run als failed in jobRunsTable.
1013
+ await seedPendingJob();
1014
+ await expect(
1015
+ runExportJobs({
1016
+ db: stack.db,
1017
+ registry: stack.registry,
1018
+ buildStorageProvider: buildProvider,
1019
+ now: NOW(),
1020
+ sendExportReadyEmail: async () => {
1021
+ // wird nicht erreicht — fireExportReadyCallback throws vorher
1022
+ },
1023
+ // appExportDownloadUrl absichtlich fehlt
1024
+ }),
1025
+ ).rejects.toThrow(/appExportDownloadUrl fehlt/);
1026
+ });
1027
+
1028
+ test("user ohne email → Callback skipped + console.warn (kein Throw)", async () => {
1029
+ // User-Row mit email=null seeden (override). Worker logged warn,
1030
+ // Callback wird NICHT gerufen, Worker-Run bleibt successful.
1031
+ await stack.db.delete(userTable);
1032
+ await stack.db.insert(userTable).values({
1033
+ id: String(aliceUser.id),
1034
+ tenantId: tenantA,
1035
+ email: "" as string, // empty string — lookupUserEmail returnt null
1036
+ passwordHash: "h",
1037
+ displayName: "Alice",
1038
+ locale: "de",
1039
+ emailVerified: true,
1040
+ roles: '["Member"]',
1041
+ status: USER_STATUS.Active,
1042
+ });
1043
+ const jobId = await seedPendingJob();
1044
+ let callbackInvoked = false;
1045
+ await runExportJobs({
1046
+ db: stack.db,
1047
+ registry: stack.registry,
1048
+ buildStorageProvider: buildProvider,
1049
+ now: NOW(),
1050
+ sendExportReadyEmail: async () => {
1051
+ callbackInvoked = true;
1052
+ },
1053
+ appExportDownloadUrl: "https://app/user-export/by-token",
1054
+ });
1055
+ // Callback NICHT aufgerufen weil userEmail leer
1056
+ expect(callbackInvoked).toBe(false);
1057
+ // Job ist trotzdem done (Notification ist best-effort, Audit-Trail
1058
+ // existiert via Token-DB-row)
1059
+ const [row] = (await stack.db
1060
+ .select()
1061
+ .from(exportJobsTable)
1062
+ .where(sql`id = ${jobId}`)) as Array<{ status: string }>;
1063
+ expect(row?.status).toBe("done");
1064
+ });
1065
+
1066
+ test("Atom 5.fix3 best-effort: callback-Throw fuer Job A killt Batch NICHT — Job B trotzdem verarbeitet", async () => {
1067
+ // Vor fix3 wuerde ein Throw aus sendExportReadyEmail die for-Schleife
1068
+ // abwuergen. Job A's Status ist bereits done committed, retry findet
1069
+ // niemand mehr (alle pending-Jobs done) → silent miss + ZIP laeuft
1070
+ // nach TTL ab ohne dass User je die Email bekommt.
1071
+ //
1072
+ // Mit fix3: try/catch faengt den Throw, console.warn macht's
1073
+ // operator-sichtbar, Schleife laeuft weiter zu Job B.
1074
+ const bobUser = createTestUser({
1075
+ id: 7,
1076
+ tenantId: tenantA,
1077
+ roles: ["Member"],
1078
+ });
1079
+ await stack.db.insert(userTable).values({
1080
+ id: String(bobUser.id),
1081
+ tenantId: tenantA,
1082
+ email: "bob@example.com",
1083
+ passwordHash: "h",
1084
+ displayName: "Bob",
1085
+ locale: "de",
1086
+ emailVerified: true,
1087
+ roles: '["Member"]',
1088
+ status: USER_STATUS.Active,
1089
+ });
1090
+
1091
+ const jobAId = await seedPendingJob();
1092
+ const jobBId = await seedPendingJob({ user: bobUser });
1093
+
1094
+ const calls: string[] = [];
1095
+ const result = await runExportJobs({
1096
+ db: stack.db,
1097
+ registry: stack.registry,
1098
+ buildStorageProvider: buildProvider,
1099
+ now: NOW(),
1100
+ sendExportReadyEmail: async (sentArgs) => {
1101
+ calls.push(sentArgs.jobId);
1102
+ if (sentArgs.jobId === jobAId) {
1103
+ throw new Error("synthetic email transport failure for job A");
1104
+ }
1105
+ },
1106
+ appExportDownloadUrl: "https://app.example.com/user-export/by-token",
1107
+ });
1108
+
1109
+ // Beide Jobs durchgegangen — Throw bei Job A hat Job B nicht
1110
+ // mitgerissen. Beweis fuer try/catch-Continuation.
1111
+ expect(result.completedJobIds).toContain(jobAId);
1112
+ expect(result.completedJobIds).toContain(jobBId);
1113
+ expect(calls.sort()).toEqual([jobAId, jobBId].sort());
1114
+
1115
+ // Beide DB-Rows tatsaechlich done (Job A's Status wurde VOR dem
1116
+ // Email-Versand committed — der Throw aenderte daran nichts).
1117
+ const rows = (await stack.db
1118
+ .select()
1119
+ .from(exportJobsTable)
1120
+ .where(sql`id IN (${jobAId}, ${jobBId})`)) as Array<{ id: string; status: string }>;
1121
+ expect(rows).toHaveLength(2);
1122
+ expect(rows.every((r) => r.status === "done")).toBe(true);
1123
+ });
1124
+ });