@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,565 @@
1
+ // Download-Endpoint Integration-Tests (S2.U3 Atom 4b).
2
+ //
3
+ // Pinst beide Pfade:
4
+ // - download-by-token (Magic-Link, anonymous)
5
+ // - download-by-job (UI-Klick, session-auth)
6
+ //
7
+ // Plus Audit-Updates (useCount, lastUsedAt, IP, UA), TTL-checks,
8
+ // cross-user-isolation, cross-tenant-same-user.
9
+
10
+ import { randomBytes } from "node:crypto";
11
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
12
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
14
+ import {
15
+ createInMemoryFileProvider,
16
+ type FileStorageProvider,
17
+ } from "@cosmicdrift/kumiko-framework/files";
18
+ import {
19
+ createTestUser,
20
+ setupTestStack,
21
+ type TestStack,
22
+ testTenantId,
23
+ unsafeCreateEntityTable,
24
+ unsafePushTables,
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 { createConfigFeature } from "../../config";
34
+ import { ConfigHandlers } from "../../config/constants";
35
+ import { createConfigAccessorFactory } from "../../config/feature";
36
+ import { createConfigResolver } from "../../config/resolver";
37
+ import { configValuesTable } from "../../config/table";
38
+ import { createDataRetentionFeature } from "../../data-retention";
39
+ import { fileFoundationFeature } from "../../file-foundation";
40
+ import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
41
+ import { createUserFeature } from "../../user";
42
+ import { createUserDataRightsFeature } from "../feature";
43
+ import { runExportJobs } from "../run-export-jobs";
44
+ import { exportDownloadTokenEntity, exportDownloadTokensTable } from "../schema/download-token";
45
+ import { exportJobEntity, exportJobsTable } from "../schema/export-job";
46
+
47
+ let stack: TestStack;
48
+ let providerPerTenant: Map<string, ReturnType<typeof createInMemoryFileProvider>>;
49
+
50
+ const tenantA = testTenantId(1);
51
+ const tenantB = testTenantId(2);
52
+ const aliceUser = createTestUser({ id: 42, tenantId: tenantA, roles: ["Member"] });
53
+ const bobUser = createTestUser({ id: 43, tenantId: tenantA, roles: ["Member"] });
54
+ // Admin fuer config-set (file-foundation:provider="inmemory")
55
+ const tenantAdmin = createTestUser({
56
+ id: 99,
57
+ tenantId: tenantA,
58
+ roles: ["TenantAdmin", "SystemAdmin"],
59
+ });
60
+
61
+ const testEncryptionKey = randomBytes(32).toString("base64");
62
+
63
+ // Test-only file-provider OHNE getSignedUrl. Pinst dass der Code-Pfad
64
+ // signedUrlNotSupported einen UnprocessableError (422) wirft, nicht
65
+ // generic 404. Memory `feedback_no_fake_tests`: Code-Fix ohne Test
66
+ // waere theatre.
67
+ const noSignedUrlProviderFeature = defineFeature("test-no-signed-url-provider", (r) => {
68
+ r.requires("file-foundation");
69
+ r.useExtension("fileProvider", "no-signed-url", {
70
+ build: async () => ({
71
+ async write() {
72
+ // no-op fuer dieses Test-Setup
73
+ },
74
+ async writeStream() {
75
+ // no-op
76
+ },
77
+ async read() {
78
+ return new Uint8Array();
79
+ },
80
+ readStream() {
81
+ return {
82
+ async *[Symbol.asyncIterator]() {
83
+ yield new Uint8Array();
84
+ },
85
+ };
86
+ },
87
+ async delete() {
88
+ // no-op
89
+ },
90
+ async exists() {
91
+ return true;
92
+ },
93
+ // **kein** getSignedUrl — pinst den 422-Pfad
94
+ }),
95
+ });
96
+ });
97
+
98
+ beforeAll(async () => {
99
+ const encryption = createEncryptionProvider(testEncryptionKey);
100
+ const resolver = createConfigResolver({ encryption });
101
+
102
+ stack = await setupTestStack({
103
+ features: [
104
+ createConfigFeature(),
105
+ createUserFeature(),
106
+ createDataRetentionFeature(),
107
+ createComplianceProfilesFeature(),
108
+ fileFoundationFeature,
109
+ fileProviderInMemoryFeature,
110
+ noSignedUrlProviderFeature,
111
+ createUserDataRightsFeature(),
112
+ ],
113
+ extraContext: ({ registry }) => ({
114
+ configResolver: resolver,
115
+ configEncryption: encryption,
116
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
117
+ }),
118
+ // Anonymous-Access fuer den Magic-Link-Pfad (Atom 4b httpRoute-Wrapper).
119
+ // tenant-context kommt von job.requestedFromTenantId nach Token-Lookup;
120
+ // defaultTenantId hier ist nur Fallback wenn kein X-Tenant-Header da ist.
121
+ anonymousAccess: {
122
+ defaultTenantId: tenantA,
123
+ },
124
+ });
125
+ await unsafeCreateEntityTable(stack.db, exportJobEntity);
126
+ await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
127
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
128
+ await unsafePushTables(stack.db, { configValuesTable });
129
+ await createEventsTable(stack.db);
130
+ await stack.db.execute(sql`
131
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
132
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
133
+ tenant_id UUID NOT NULL,
134
+ user_id TEXT NOT NULL,
135
+ version INTEGER NOT NULL DEFAULT 0,
136
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
137
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
138
+ inserted_by_id TEXT,
139
+ modified_by_id TEXT,
140
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
141
+ deleted_at TIMESTAMPTZ,
142
+ deleted_by_id TEXT,
143
+ roles TEXT NOT NULL DEFAULT '[]',
144
+ UNIQUE(user_id, tenant_id)
145
+ )
146
+ `);
147
+ });
148
+
149
+ afterAll(async () => {
150
+ await stack.cleanup();
151
+ });
152
+
153
+ beforeEach(async () => {
154
+ await stack.db.delete(exportDownloadTokensTable);
155
+ await stack.db.delete(exportJobsTable);
156
+ await stack.db.execute(sql`DELETE FROM kumiko_events`);
157
+ await stack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
158
+ await stack.db.execute(sql`DELETE FROM read_tenant_memberships`);
159
+ await stack.db.execute(sql`DELETE FROM ${configValuesTable}`);
160
+ providerPerTenant = new Map();
161
+
162
+ // Setup file-foundation provider="inmemory" pro Tenant.
163
+ // Admin auf jeweiligem Tenant (tenant-config-key).
164
+ await stack.http.writeOk(
165
+ ConfigHandlers.set,
166
+ { key: "file-foundation:config:provider", value: "inmemory" },
167
+ tenantAdmin,
168
+ );
169
+ await stack.http.writeOk(
170
+ ConfigHandlers.set,
171
+ { key: "file-foundation:config:provider", value: "inmemory" },
172
+ { ...tenantAdmin, tenantId: tenantB },
173
+ );
174
+ });
175
+
176
+ const NOW = () => getTemporal().Now.instant();
177
+
178
+ function buildProvider(tenantId: string): Promise<FileStorageProvider> {
179
+ let p = providerPerTenant.get(tenantId);
180
+ if (!p) {
181
+ p = createInMemoryFileProvider();
182
+ providerPerTenant.set(tenantId, p);
183
+ }
184
+ return Promise.resolve(p);
185
+ }
186
+
187
+ /**
188
+ * Helper: completed Job + Token via realen Worker-Pfad.
189
+ * Returns {jobId, plainToken} fuer Test-Use.
190
+ */
191
+ async function seedDoneJobWithToken(): Promise<{ jobId: string; plainToken: string }> {
192
+ // 1. seed pending Job via real handler
193
+ const requestRes = await stack.http.writeOk<{ jobId: string }>(
194
+ "user-data-rights:write:request-export",
195
+ {},
196
+ aliceUser,
197
+ );
198
+ const jobId = requestRes.jobId;
199
+
200
+ // 2. seed in-memory ZIP file at path that worker would write
201
+ const provider = await buildProvider(tenantA);
202
+ const storageKey = `${tenantA}/exports/${jobId}.zip`;
203
+ await provider.write(storageKey, new Uint8Array([1, 2, 3]));
204
+
205
+ // 3. Run worker → done-flip + Token-Create
206
+ const result = await runExportJobs({
207
+ db: stack.db,
208
+ registry: stack.registry,
209
+ buildStorageProvider: buildProvider,
210
+ now: NOW(),
211
+ });
212
+ const plainToken = result.tokenByJobId.get(jobId);
213
+ if (!plainToken) {
214
+ throw new Error("seedDoneJobWithToken: token-create failed in worker run");
215
+ }
216
+ return { jobId, plainToken };
217
+ }
218
+
219
+ describe("download-by-token :: happy path", () => {
220
+ test("valid Token → returns signed URL + audit-update", async () => {
221
+ const { jobId, plainToken } = await seedDoneJobWithToken();
222
+
223
+ const res = await stack.http.query(
224
+ "user-data-rights:query:download-by-token",
225
+ {
226
+ token: plainToken,
227
+ auditMeta: { ip: "192.168.1.42", userAgent: "test-agent/1.0" },
228
+ },
229
+ aliceUser, // anonymous-pfad akzeptiert beliebige user
230
+ );
231
+ const body = (await res.json()) as {
232
+ data?: { url?: string; expiresAt?: string; bytesWritten?: number | null };
233
+ error?: unknown;
234
+ };
235
+ if (!body.data?.url) {
236
+ throw new Error(`Expected url in response. Got: ${JSON.stringify(body)}`);
237
+ }
238
+ const result = body.data;
239
+
240
+ expect(result.url).toMatch(/^memory:\/\//);
241
+ expect(result.url).toContain(`${tenantA}/exports/${jobId}.zip`);
242
+ expect(result.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
243
+
244
+ // Audit-Update: useCount=1, lastUsedAt set, IP+UA persistiert
245
+ const tokenRows = (await stack.db
246
+ .select()
247
+ .from(exportDownloadTokensTable)
248
+ .where(sql`job_id = ${jobId}`)) as Array<{
249
+ useCount: number;
250
+ lastUsedAt: { toString(): string } | null;
251
+ lastUsedFromIp: string | null;
252
+ lastUsedUserAgent: string | null;
253
+ }>;
254
+ expect(tokenRows[0]?.useCount).toBe(1);
255
+ expect(tokenRows[0]?.lastUsedAt).not.toBeNull();
256
+ expect(tokenRows[0]?.lastUsedFromIp).toBe("192.168.1.42");
257
+ expect(tokenRows[0]?.lastUsedUserAgent).toBe("test-agent/1.0");
258
+ });
259
+
260
+ test("Multi-use within TTL: 2× Downloads → useCount=2", async () => {
261
+ const { jobId, plainToken } = await seedDoneJobWithToken();
262
+
263
+ await stack.http.queryOk(
264
+ "user-data-rights:query:download-by-token",
265
+ { token: plainToken },
266
+ aliceUser,
267
+ );
268
+ await stack.http.queryOk(
269
+ "user-data-rights:query:download-by-token",
270
+ { token: plainToken },
271
+ aliceUser,
272
+ );
273
+
274
+ const [row] = (await stack.db
275
+ .select()
276
+ .from(exportDownloadTokensTable)
277
+ .where(sql`job_id = ${jobId}`)) as Array<{ useCount: number }>;
278
+ expect(row?.useCount).toBe(2);
279
+ });
280
+ });
281
+
282
+ describe("download-by-token :: error paths", () => {
283
+ test("invalid Token → 404 not_found", async () => {
284
+ await seedDoneJobWithToken();
285
+ const res = await stack.http.query(
286
+ "user-data-rights:query:download-by-token",
287
+ { token: "definitely-not-a-real-token-xxxxx" },
288
+ aliceUser,
289
+ );
290
+ expect(res.status).toBe(404);
291
+ const body = (await res.json()) as { error: { code: string; i18nKey: string } };
292
+ expect(body.error.code).toBe("not_found");
293
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.notFound");
294
+ });
295
+
296
+ test("expired Token → 404 download.expired", async () => {
297
+ const { jobId, plainToken } = await seedDoneJobWithToken();
298
+ const longAgo = getTemporal().Instant.fromEpochMilliseconds(
299
+ Date.now() - 365 * 24 * 60 * 60 * 1000,
300
+ );
301
+ await stack.db
302
+ .update(exportDownloadTokensTable)
303
+ .set({ expiresAt: longAgo })
304
+ .where(sql`job_id = ${jobId}`);
305
+
306
+ const res = await stack.http.query(
307
+ "user-data-rights:query:download-by-token",
308
+ { token: plainToken },
309
+ aliceUser,
310
+ );
311
+ expect(res.status).toBe(404);
312
+ const body = (await res.json()) as { error: { i18nKey: string } };
313
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.expired");
314
+ });
315
+
316
+ test("failed Job → 404 download.unavailable", async () => {
317
+ const { jobId, plainToken } = await seedDoneJobWithToken();
318
+ await stack.db.update(exportJobsTable).set({ status: "failed" }).where(sql`id = ${jobId}`);
319
+
320
+ const res = await stack.http.query(
321
+ "user-data-rights:query:download-by-token",
322
+ { token: plainToken },
323
+ aliceUser,
324
+ );
325
+ expect(res.status).toBe(404);
326
+ const body = (await res.json()) as { error: { i18nKey: string } };
327
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.unavailable");
328
+ });
329
+
330
+ test("storage cleared → 404 download.expired", async () => {
331
+ const { jobId, plainToken } = await seedDoneJobWithToken();
332
+ await stack.db
333
+ .update(exportJobsTable)
334
+ .set({ downloadStorageKey: null })
335
+ .where(sql`id = ${jobId}`);
336
+
337
+ const res = await stack.http.query(
338
+ "user-data-rights:query:download-by-token",
339
+ { token: plainToken },
340
+ aliceUser,
341
+ );
342
+ expect(res.status).toBe(404);
343
+ const body = (await res.json()) as { error: { i18nKey: string } };
344
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.expired");
345
+ });
346
+
347
+ test("provider ohne getSignedUrl → 422 unprocessable signedUrlNotSupported", async () => {
348
+ // Pinst Operator-Konfig-Bug-Pfad: provider ohne getSignedUrl-Support
349
+ // (z.B. local-Filesystem-Provider in Production faelschlich gemountet)
350
+ // → 422 statt 404. DPO sieht im Log "unprocessable" + spezifischen
351
+ // i18nKey, kann den Konfig-Bug diagnostizieren.
352
+ const { jobId, plainToken } = await seedDoneJobWithToken();
353
+ // Switch tenant-config auf den no-signed-url-Provider
354
+ await stack.http.writeOk(
355
+ ConfigHandlers.set,
356
+ { key: "file-foundation:config:provider", value: "no-signed-url" },
357
+ tenantAdmin,
358
+ );
359
+
360
+ const res = await stack.http.query(
361
+ "user-data-rights:query:download-by-token",
362
+ { token: plainToken },
363
+ aliceUser,
364
+ );
365
+ expect(res.status).toBe(422);
366
+ const body = (await res.json()) as { error: { code: string; i18nKey: string } };
367
+ expect(body.error.code).toBe("unprocessable");
368
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.signedUrlNotSupported");
369
+
370
+ // Sanity: Job-Row ist immer noch done — Operator-Bug aendert nicht den Job-State
371
+ const [row] = (await stack.db
372
+ .select()
373
+ .from(exportJobsTable)
374
+ .where(sql`id = ${jobId}`)) as Array<{ status: string }>;
375
+ expect(row?.status).toBe("done");
376
+ });
377
+ });
378
+
379
+ describe("download-by-job :: happy path", () => {
380
+ test("session-auth: Job-Owner → returns signed URL + audit", async () => {
381
+ const { jobId } = await seedDoneJobWithToken();
382
+
383
+ const result = await stack.http.queryOk<{ url: string }>(
384
+ "user-data-rights:query:download-by-job",
385
+ {
386
+ jobId,
387
+ auditMeta: { ip: "10.0.0.5", userAgent: "Mozilla/5.0" },
388
+ },
389
+ aliceUser,
390
+ );
391
+
392
+ expect(result.url).toMatch(/^memory:\/\//);
393
+
394
+ // UI-Klick zaehlt auch als Use → audit-row updated
395
+ const [row] = (await stack.db
396
+ .select()
397
+ .from(exportDownloadTokensTable)
398
+ .where(sql`job_id = ${jobId}`)) as Array<{
399
+ useCount: number;
400
+ lastUsedFromIp: string | null;
401
+ }>;
402
+ expect(row?.useCount).toBe(1);
403
+ expect(row?.lastUsedFromIp).toBe("10.0.0.5");
404
+ });
405
+
406
+ test("failed Job (status != done) → 404 download.unavailable (job-Pfad)", async () => {
407
+ // Symmetrisch zum token-Test: gleicher Code-Pfad muss auch im job-
408
+ // handler 404 + unavailable raus, nicht 500.
409
+ const { jobId } = await seedDoneJobWithToken();
410
+ await stack.db.update(exportJobsTable).set({ status: "failed" }).where(sql`id = ${jobId}`);
411
+
412
+ const res = await stack.http.query(
413
+ "user-data-rights:query:download-by-job",
414
+ { jobId },
415
+ aliceUser,
416
+ );
417
+ expect(res.status).toBe(404);
418
+ const body = (await res.json()) as { error: { i18nKey: string } };
419
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.unavailable");
420
+ });
421
+
422
+ test("storage cleared (downloadStorageKey null) → 404 download.expired (job-Pfad)", async () => {
423
+ const { jobId } = await seedDoneJobWithToken();
424
+ await stack.db
425
+ .update(exportJobsTable)
426
+ .set({ downloadStorageKey: null })
427
+ .where(sql`id = ${jobId}`);
428
+
429
+ const res = await stack.http.query(
430
+ "user-data-rights:query:download-by-job",
431
+ { jobId },
432
+ aliceUser,
433
+ );
434
+ expect(res.status).toBe(404);
435
+ const body = (await res.json()) as { error: { i18nKey: string } };
436
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.expired");
437
+ });
438
+ });
439
+
440
+ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
441
+ test("happy: 302-Redirect mit Location-Header zur signed-URL", async () => {
442
+ const { plainToken } = await seedDoneJobWithToken();
443
+
444
+ const res = await stack.app.fetch(
445
+ new Request(`http://test/user-export/by-token?token=${plainToken}`, {
446
+ method: "GET",
447
+ headers: {
448
+ "user-agent": "e2e-test/1.0",
449
+ "x-forwarded-for": "203.0.113.42",
450
+ },
451
+ }),
452
+ );
453
+ expect(res.status).toBe(302);
454
+ const location = res.headers.get("location");
455
+ expect(location).toBeTruthy();
456
+ expect(location).toMatch(/^memory:\/\//);
457
+ });
458
+
459
+ test("invalid token → 404 passthrough mit i18nKey", async () => {
460
+ const res = await stack.app.fetch(
461
+ new Request("http://test/user-export/by-token?token=fake-xxxxx", {
462
+ method: "GET",
463
+ }),
464
+ );
465
+ expect(res.status).toBe(404);
466
+ const body = (await res.json()) as { error?: { i18nKey?: string } };
467
+ expect(body.error?.i18nKey).toBe("userDataRights.errors.download.notFound");
468
+ });
469
+
470
+ test("missing token query-param → 400", async () => {
471
+ const res = await stack.app.fetch(
472
+ new Request("http://test/user-export/by-token", { method: "GET" }),
473
+ );
474
+ expect(res.status).toBe(400);
475
+ const body = (await res.json()) as { error?: string };
476
+ expect(body.error).toBe("missing_token");
477
+ });
478
+
479
+ test("Audit-Update: useCount + IP/UA aus httpRoute-Headers (nicht aus payload)", async () => {
480
+ const { jobId, plainToken } = await seedDoneJobWithToken();
481
+ await stack.app.fetch(
482
+ new Request(`http://test/user-export/by-token?token=${plainToken}`, {
483
+ method: "GET",
484
+ headers: {
485
+ "user-agent": "e2e-test/2.0",
486
+ "x-forwarded-for": "198.51.100.7, 10.0.0.1",
487
+ },
488
+ }),
489
+ );
490
+
491
+ const [row] = (await stack.db
492
+ .select()
493
+ .from(exportDownloadTokensTable)
494
+ .where(sql`job_id = ${jobId}`)) as Array<{
495
+ useCount: number;
496
+ lastUsedFromIp: string | null;
497
+ lastUsedUserAgent: string | null;
498
+ }>;
499
+ expect(row?.useCount).toBe(1);
500
+ // X-Forwarded-For: erster Wert, comma-trimmed
501
+ expect(row?.lastUsedFromIp).toBe("198.51.100.7");
502
+ expect(row?.lastUsedUserAgent).toBe("e2e-test/2.0");
503
+ });
504
+ });
505
+
506
+ describe("download-by-job :: cross-user + cross-tenant", () => {
507
+ test("cross-user: Bob requests Alice's Job → 404 not_found (no existence leak)", async () => {
508
+ const { jobId } = await seedDoneJobWithToken();
509
+
510
+ const res = await stack.http.query(
511
+ "user-data-rights:query:download-by-job",
512
+ { jobId },
513
+ bobUser,
514
+ );
515
+ expect(res.status).toBe(404);
516
+ const body = (await res.json()) as { error: { code: string; i18nKey: string } };
517
+ expect(body.error.code).toBe("not_found");
518
+ // Selber i18nKey wie invalid-token → keine Probing-Differenz
519
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.notFound");
520
+ });
521
+
522
+ test("provider ohne getSignedUrl → 422 unprocessable signedUrlNotSupported (job-Pfad)", async () => {
523
+ // Symmetrisch zum token-Pfad: derselbe Operator-Konfig-Bug muss auch
524
+ // beim UI-Klick-Pfad als 422 raus, nicht 404.
525
+ const { jobId } = await seedDoneJobWithToken();
526
+ await stack.http.writeOk(
527
+ ConfigHandlers.set,
528
+ { key: "file-foundation:config:provider", value: "no-signed-url" },
529
+ tenantAdmin,
530
+ );
531
+
532
+ const res = await stack.http.query(
533
+ "user-data-rights:query:download-by-job",
534
+ { jobId },
535
+ aliceUser,
536
+ );
537
+ expect(res.status).toBe(422);
538
+ const body = (await res.json()) as { error: { code: string; i18nKey: string } };
539
+ expect(body.error.code).toBe("unprocessable");
540
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.signedUrlNotSupported");
541
+ });
542
+
543
+ test("cross-tenant same-user: Alice from Tenant B downloadet Tenant-A-Job → success", async () => {
544
+ const { jobId } = await seedDoneJobWithToken();
545
+ // Alice loggt sich in Tenant B ein
546
+ const aliceFromTenantB = createTestUser({
547
+ id: 42,
548
+ tenantId: tenantB,
549
+ roles: ["Member"],
550
+ });
551
+ // Membership in Tenant B persisten damit auth-stack User akzeptiert
552
+ await stack.db.execute(sql`
553
+ INSERT INTO read_tenant_memberships (tenant_id, user_id)
554
+ VALUES (${tenantB}, ${String(aliceUser.id)})
555
+ ON CONFLICT (user_id, tenant_id) DO NOTHING
556
+ `);
557
+
558
+ const result = await stack.http.queryOk<{ url: string }>(
559
+ "user-data-rights:query:download-by-job",
560
+ { jobId },
561
+ aliceFromTenantB,
562
+ );
563
+ expect(result.url).toMatch(/^memory:\/\//);
564
+ });
565
+ });