@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/package.json +12 -6
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +33 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +334 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,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
|
+
});
|