@cosmicdrift/kumiko-bundled-features 0.79.0 → 0.79.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/package.json +6 -6
- package/src/user-data-rights/__tests__/download.integration.test.ts +11 -8
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +148 -0
- package/src/user-data-rights/constants.ts +1 -7
- package/src/user-data-rights/feature.ts +39 -55
- package/src/user-data-rights/handlers/download-by-job.query.ts +10 -10
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +2 -3
- package/src/user-data-rights/web/privacy-center-screen.tsx +19 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.79.
|
|
3
|
+
"version": "0.79.3",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.79.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.79.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.79.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.79.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.79.
|
|
87
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.79.3",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.79.3",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.79.3",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer": "0.79.3",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.79.3",
|
|
92
92
|
"@mollie/api-client": "^4.5.0",
|
|
93
93
|
"@node-rs/argon2": "^2.0.2",
|
|
94
94
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -374,19 +374,22 @@ describe("download-by-token :: error paths", () => {
|
|
|
374
374
|
});
|
|
375
375
|
|
|
376
376
|
describe("download-by-job :: happy path", () => {
|
|
377
|
-
test("session-auth: Job-Owner → returns signed URL + audit", async () => {
|
|
377
|
+
test("session-auth: Job-Owner → returns signed URL + audit (IP aus X-Forwarded-For)", async () => {
|
|
378
378
|
const { jobId } = await seedDoneJobWithToken();
|
|
379
379
|
|
|
380
|
-
|
|
380
|
+
// Der UI-Klick laeuft als direkter download-by-job-Query (Client traegt
|
|
381
|
+
// X-CSRF-Token). Die Audit-IP kommt server-trusted aus dem RequestContext
|
|
382
|
+
// (X-Forwarded-For, erster Hop), nicht aus einem vom Client mitgeschickten
|
|
383
|
+
// Feld.
|
|
384
|
+
const res = await stack.http.queryWithHeaders(
|
|
381
385
|
"user-data-rights:query:download-by-job",
|
|
382
|
-
{
|
|
383
|
-
jobId,
|
|
384
|
-
auditMeta: { ip: "10.0.0.5", userAgent: "Mozilla/5.0" },
|
|
385
|
-
},
|
|
386
|
+
{ jobId },
|
|
386
387
|
aliceUser,
|
|
388
|
+
{ "x-forwarded-for": "10.0.0.5, 10.0.0.1" },
|
|
387
389
|
);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
+
expect(res.status).toBe(200);
|
|
391
|
+
const body = (await res.json()) as { data: { url: string } };
|
|
392
|
+
expect(body.data.url).toMatch(/^memory:\/\//);
|
|
390
393
|
|
|
391
394
|
// UI-Klick zaehlt auch als Use → audit-row updated
|
|
392
395
|
const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Treibt den ECHTEN registrierten Export-Cron-Job (r.job "run-export-jobs")
|
|
2
|
+
// über seinen Job-Kontext — so wie der Job-Runner ihn in prod aufruft:
|
|
3
|
+
// `ctx.configResolver` gesetzt (App-Override provider=inmemory), aber KEIN
|
|
4
|
+
// per-request `ctx.config` (das baut nur der HTTP-Dispatcher).
|
|
5
|
+
//
|
|
6
|
+
// Der bestehende run-export-jobs-Test reicht `buildStorageProvider` MANUELL —
|
|
7
|
+
// und übersprang damit genau diesen Pfad: der Wrapper baut providerCtx aus dem
|
|
8
|
+
// Job-Kontext. Ohne den configResolver→ConfigAccessor-Bau wirft
|
|
9
|
+
// createFileProviderForTenant "ctx.config is missing" (genau der prod-Bug, der
|
|
10
|
+
// jeden Export auf "failed" setzte).
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
|
+
import {
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
unsafePushTables,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
23
|
+
import {
|
|
24
|
+
createComplianceProfilesFeature,
|
|
25
|
+
tenantComplianceProfileEntity,
|
|
26
|
+
} from "../../compliance-profiles";
|
|
27
|
+
import { configValuesTable, createConfigFeature, createConfigResolver } from "../../config";
|
|
28
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
29
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
30
|
+
import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
|
|
31
|
+
import { createSessionsFeature } from "../../sessions";
|
|
32
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
33
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
34
|
+
import { exportDownloadTokenEntity } from "../schema/download-token";
|
|
35
|
+
import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "../schema/export-job";
|
|
36
|
+
|
|
37
|
+
const TENANT = "00000000-0000-4000-8000-0000000009a1";
|
|
38
|
+
const USER_ID = "00000000-0000-4000-8000-0000000009b1";
|
|
39
|
+
const JOB_QN = "user-data-rights:job:run-export-jobs";
|
|
40
|
+
|
|
41
|
+
// App-weiter Override wie money-horse's cashColtConfigResolver — provider=inmemory
|
|
42
|
+
// ohne per-Tenant-config-Row. Der Job-Kontext trägt DIESEN resolver, kein config.
|
|
43
|
+
const configResolver = createConfigResolver({
|
|
44
|
+
appOverrides: new Map([["file-foundation:config:provider", "inmemory"]]),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let stack: TestStack;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
stack = await setupTestStack({
|
|
51
|
+
features: [
|
|
52
|
+
createConfigFeature(),
|
|
53
|
+
createUserFeature(),
|
|
54
|
+
createDataRetentionFeature(),
|
|
55
|
+
createComplianceProfilesFeature(),
|
|
56
|
+
fileFoundationFeature,
|
|
57
|
+
fileProviderInMemoryFeature,
|
|
58
|
+
createSessionsFeature(),
|
|
59
|
+
createUserDataRightsFeature(),
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
await createEventsTable(stack.db);
|
|
63
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
64
|
+
await unsafeCreateEntityTable(stack.db, exportJobEntity);
|
|
65
|
+
await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
|
|
66
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
67
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
68
|
+
await asRawClient(stack.db).unsafe(`
|
|
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
|
+
const raw = asRawClient(stack.db);
|
|
93
|
+
await raw.unsafe("DELETE FROM read_export_jobs");
|
|
94
|
+
await raw.unsafe("DELETE FROM read_users");
|
|
95
|
+
await raw.unsafe("DELETE FROM read_tenant_memberships");
|
|
96
|
+
await insertOne(stack.db, userTable, {
|
|
97
|
+
id: USER_ID,
|
|
98
|
+
tenantId: TENANT,
|
|
99
|
+
email: "export-cron@example.test",
|
|
100
|
+
passwordHash: "hashed",
|
|
101
|
+
displayName: "Cron Export",
|
|
102
|
+
locale: "de",
|
|
103
|
+
emailVerified: true,
|
|
104
|
+
roles: '["Member"]',
|
|
105
|
+
status: USER_STATUS.Active,
|
|
106
|
+
});
|
|
107
|
+
await raw.unsafe(
|
|
108
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles) VALUES ('${TENANT}', '${USER_ID}', '["Member"]')`,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Seedet einen pending Export-Job über den echten request-export-Handler.
|
|
113
|
+
async function seedPendingJob(): Promise<string> {
|
|
114
|
+
const res = await stack.http.writeOk<{ jobId: string }>(
|
|
115
|
+
"user-data-rights:write:request-export",
|
|
116
|
+
{},
|
|
117
|
+
{ id: USER_ID, tenantId: TENANT, roles: ["Member"] },
|
|
118
|
+
);
|
|
119
|
+
return res.jobId;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("run-export-jobs cron-context", () => {
|
|
123
|
+
test("Cron-Job-Kontext (configResolver, KEIN config) → Export läuft durch, bytesWritten > 0", async () => {
|
|
124
|
+
const jobId = await seedPendingJob();
|
|
125
|
+
const job = stack.registry.getJob(JOB_QN);
|
|
126
|
+
expect(job).toBeDefined();
|
|
127
|
+
|
|
128
|
+
// EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
|
|
129
|
+
const jobCtx = {
|
|
130
|
+
db: stack.db,
|
|
131
|
+
registry: stack.registry,
|
|
132
|
+
configResolver,
|
|
133
|
+
_userId: SYSTEM_USER_ID,
|
|
134
|
+
now: getTemporal().Now.instant(),
|
|
135
|
+
};
|
|
136
|
+
// Vor dem Fix wirft der Wrapper hier "ctx.config is missing".
|
|
137
|
+
await job?.handler({}, jobCtx as never);
|
|
138
|
+
|
|
139
|
+
const [row] = (await selectMany(stack.db, exportJobsTable, { id: jobId })) as Array<{
|
|
140
|
+
status: string;
|
|
141
|
+
bytesWritten: number | null;
|
|
142
|
+
errorMessage: string | null;
|
|
143
|
+
}>;
|
|
144
|
+
expect(row?.errorMessage).toBeNull();
|
|
145
|
+
expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
|
|
146
|
+
expect(row?.bytesWritten ?? 0).toBeGreaterThan(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -13,6 +13,7 @@ export const PRIVACY_CENTER_SCREEN_ID = "privacy-center" as const;
|
|
|
13
13
|
export const UserDataRightsQueries = {
|
|
14
14
|
exportStatus: "user-data-rights:query:export-status",
|
|
15
15
|
myAuditLog: "user-data-rights:query:my-audit-log",
|
|
16
|
+
downloadByJob: "user-data-rights:query:download-by-job",
|
|
16
17
|
} as const;
|
|
17
18
|
|
|
18
19
|
export const UserDataRightsHandlers = {
|
|
@@ -28,13 +29,6 @@ export const UserDataRightsHandlers = {
|
|
|
28
29
|
// Screen-Test vergleicht gegen UserQueries.me.
|
|
29
30
|
export const USER_ME_QUERY = "user:query:user:me" as const;
|
|
30
31
|
|
|
31
|
-
// Download-Pfad des fertigen Export-Bundles: der dokumentierte UI-Klick-Pfad
|
|
32
|
-
// (r.httpRoute in feature.ts), der per 302 auf die signed Storage-URL
|
|
33
|
-
// weiterleitet. Anchor-navigierbar (Cookie-Auth wird mitgesendet).
|
|
34
|
-
export function userExportByJobPath(jobId: string): string {
|
|
35
|
-
return `/user-export/by-job/${jobId}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
32
|
// Client-safe Mirror von EXPORT_JOB_STATUS (schema/export-job.ts ist
|
|
39
33
|
// server-only via Drizzle-Import). Drift-Schutz: der Screen-Test vergleicht
|
|
40
34
|
// gegen die Schema-Originale.
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
SYSTEM_USER_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { createConfigAccessor } from "../config";
|
|
7
8
|
import { createFileProviderForTenant } from "../file-foundation";
|
|
8
9
|
import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
|
|
9
10
|
import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
|
|
@@ -219,17 +220,18 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
219
220
|
access: { openToAll: true },
|
|
220
221
|
});
|
|
221
222
|
|
|
222
|
-
//
|
|
223
|
+
// Magic-Link-Pfad (anonymous): GET /user-export/by-token?token=<plain>.
|
|
224
|
+
// Ruft via app.fetch /api/query → success: 302-Redirect zur signed-URL →
|
|
225
|
+
// Browser folgt → Download startet beim Object-Store. Bei error:
|
|
226
|
+
// passthrough (404/410/501) als JSON.
|
|
223
227
|
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// Bei error: passthrough (404/410/501) als JSON.
|
|
228
|
+
// Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace nicht
|
|
229
|
+
// claimen darf (reserved fuer write/query/batch/auth/sse-dispatcher).
|
|
227
230
|
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// dispatcher).
|
|
231
|
+
// Der Session-Pfad (eingeloggter Download) braucht KEINEN httpRoute-
|
|
232
|
+
// Wrapper: der Client ruft download-by-job direkt via Dispatcher (traegt
|
|
233
|
+
// X-CSRF-Token mit) und navigiert auf die zurueckgegebene signed-URL —
|
|
234
|
+
// siehe postWithDownload im privacy-center-screen.
|
|
233
235
|
r.httpRoute({
|
|
234
236
|
method: "GET",
|
|
235
237
|
path: "/user-export/by-token",
|
|
@@ -254,33 +256,6 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
254
256
|
},
|
|
255
257
|
});
|
|
256
258
|
|
|
257
|
-
// **Session-Pfad (auth):** GET /user-export/by-job/:jobId
|
|
258
|
-
r.httpRoute({
|
|
259
|
-
method: "GET",
|
|
260
|
-
path: "/user-export/by-job/:jobId",
|
|
261
|
-
handler: async (c, { app }) => {
|
|
262
|
-
const url = new URL(c.req.url);
|
|
263
|
-
const jobId = c.req.param("jobId");
|
|
264
|
-
if (!jobId) {
|
|
265
|
-
return c.json({ error: "missing_job_id" }, 400);
|
|
266
|
-
}
|
|
267
|
-
const queryRes = await app.fetch(
|
|
268
|
-
new Request(`${url.origin}/api/query`, {
|
|
269
|
-
method: "POST",
|
|
270
|
-
headers: {
|
|
271
|
-
"content-type": "application/json",
|
|
272
|
-
...forwardAuthHeaders(c.req.raw.headers),
|
|
273
|
-
},
|
|
274
|
-
body: JSON.stringify({
|
|
275
|
-
type: "user-data-rights:query:download-by-job",
|
|
276
|
-
payload: { jobId, auditMeta: extractAuditMeta(c.req.raw.headers) },
|
|
277
|
-
}),
|
|
278
|
-
}),
|
|
279
|
-
);
|
|
280
|
-
return mapQueryResponseToRedirect(c, queryRes);
|
|
281
|
-
},
|
|
282
|
-
});
|
|
283
|
-
|
|
284
259
|
// S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
|
|
285
260
|
r.job(
|
|
286
261
|
"run-export-jobs",
|
|
@@ -295,17 +270,35 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
295
270
|
// SYSTEM_USER_ID ist die framework-weite Konvention. Der job-
|
|
296
271
|
// Discriminator wird via handlerName="user-data-rights:run-export-
|
|
297
272
|
// jobs" im Secret-Read-Audit erfasst.
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
secrets: ctx.secrets,
|
|
302
|
-
_userId: ctx._userId ?? SYSTEM_USER_ID,
|
|
303
|
-
};
|
|
273
|
+
const exportUserId = ctx._userId ?? SYSTEM_USER_ID;
|
|
274
|
+
const exportDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
|
|
275
|
+
const exportRegistry = ctx.registry;
|
|
304
276
|
await runExportJobs({
|
|
305
|
-
db:
|
|
306
|
-
registry:
|
|
307
|
-
buildStorageProvider: async (tenantId) =>
|
|
308
|
-
|
|
277
|
+
db: exportDb,
|
|
278
|
+
registry: exportRegistry,
|
|
279
|
+
buildStorageProvider: async (tenantId) => {
|
|
280
|
+
// ctx.config (per-request ConfigAccessor) existiert nur im HTTP-
|
|
281
|
+
// Dispatcher; der Cron-Job-Kontext trägt ctx.configResolver. Den
|
|
282
|
+
// per-Tenant-Accessor daraus bauen (wie der HTTP-Pfad via
|
|
283
|
+
// _configAccessorFactory) — sonst wirft createFileProviderForTenant
|
|
284
|
+
// "ctx.config is missing" und jeder Export landet auf failed.
|
|
285
|
+
const config =
|
|
286
|
+
ctx.config ??
|
|
287
|
+
(ctx.configResolver
|
|
288
|
+
? createConfigAccessor(
|
|
289
|
+
exportRegistry,
|
|
290
|
+
ctx.configResolver,
|
|
291
|
+
tenantId as Parameters<typeof createConfigAccessor>[2],
|
|
292
|
+
exportUserId,
|
|
293
|
+
exportDb,
|
|
294
|
+
)
|
|
295
|
+
: undefined);
|
|
296
|
+
return createFileProviderForTenant(
|
|
297
|
+
{ config, registry: exportRegistry, secrets: ctx.secrets, _userId: exportUserId },
|
|
298
|
+
tenantId,
|
|
299
|
+
"user-data-rights:run-export-jobs",
|
|
300
|
+
);
|
|
301
|
+
},
|
|
309
302
|
now: T.Now.instant(),
|
|
310
303
|
// Atom 5 — App-Author-Callbacks fuer Email-Notification.
|
|
311
304
|
// Optional: wenn nicht gesetzt, kein Email; User pollt
|
|
@@ -344,15 +337,6 @@ async function mapQueryResponseToRedirect(
|
|
|
344
337
|
return c.redirect(body.data.url, 302);
|
|
345
338
|
}
|
|
346
339
|
|
|
347
|
-
function forwardAuthHeaders(headers: Headers): Record<string, string> {
|
|
348
|
-
const out: Record<string, string> = {};
|
|
349
|
-
const auth = headers.get("authorization");
|
|
350
|
-
if (auth) out["authorization"] = auth;
|
|
351
|
-
const cookie = headers.get("cookie");
|
|
352
|
-
if (cookie) out["cookie"] = cookie;
|
|
353
|
-
return out;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
340
|
// Extract Audit-Meta (IP + UA) aus den HTTP-Headers + steck es in die
|
|
357
341
|
// query-payload. Der httpRoute-Wrapper ist trusted-source — er hat den
|
|
358
342
|
// raw-request gesehen, nicht der direkter /api/query-Caller. User der
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// 6. Audit-Update: useCount + 1, IP, UA, lastUsedAt (best-effort)
|
|
24
24
|
// 7. Return {url, expiresAt}
|
|
25
25
|
|
|
26
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
26
27
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
27
28
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
29
|
import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -54,12 +55,6 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
54
55
|
name: "download-by-job",
|
|
55
56
|
schema: z.object({
|
|
56
57
|
jobId: z.string().min(1, "jobId required"),
|
|
57
|
-
auditMeta: z
|
|
58
|
-
.object({
|
|
59
|
-
ip: z.string().nullable(),
|
|
60
|
-
userAgent: z.string().nullable(),
|
|
61
|
-
})
|
|
62
|
-
.optional(),
|
|
63
58
|
}),
|
|
64
59
|
access: { openToAll: true }, // openToAll = auth-required, kein anonymous
|
|
65
60
|
handler: async (query, ctx) => {
|
|
@@ -68,8 +63,13 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
68
63
|
const userId = query.user.id;
|
|
69
64
|
const jobId = query.payload.jobId;
|
|
70
65
|
const tenantId = query.user.tenantId;
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
// IP aus dem request-scoped Kontext (von requestIdMiddleware aus
|
|
67
|
+
// x-forwarded-for befuellt) — server-trusted, anders als ein vom Client
|
|
68
|
+
// mitgeschickter Wert. UA steht nicht im RequestContext; der Audit-Row
|
|
69
|
+
// laesst sie null (best-effort, via requestId in den Server-Logs
|
|
70
|
+
// cross-referenzierbar).
|
|
71
|
+
const auditIp = requestContext.get()?.ip ?? null;
|
|
72
|
+
const auditUa: string | null = null;
|
|
73
73
|
|
|
74
74
|
// Step 1-2: job-lookup + cross-user-isolation
|
|
75
75
|
// ctx.db.raw weil tenant-agnostisch — Alice in Tenant B sucht den
|
|
@@ -179,8 +179,8 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
179
179
|
tokenUseCount: tokenRow.useCount ?? 0,
|
|
180
180
|
tenantId: jobRow.requestedFromTenantId,
|
|
181
181
|
now,
|
|
182
|
-
ip:
|
|
183
|
-
userAgent:
|
|
182
|
+
ip: auditIp,
|
|
183
|
+
userAgent: auditUa,
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
186
|
// Wenn tokenRow fehlt (sollte nicht passieren wenn Atom 4a sauber
|
|
@@ -139,7 +139,7 @@ describe.skip("PrivacyCenterScreen", () => {
|
|
|
139
139
|
expect(view.container.textContent).not.toContain("userDataRights.privacyCenter");
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
test("export done: Download-
|
|
142
|
+
test("export done: Download-Button + Verfügbar-bis-Datum", async () => {
|
|
143
143
|
const { view } = renderCenter({
|
|
144
144
|
me: activeMe,
|
|
145
145
|
exportStatus: {
|
|
@@ -148,8 +148,7 @@ describe.skip("PrivacyCenterScreen", () => {
|
|
|
148
148
|
},
|
|
149
149
|
});
|
|
150
150
|
await waitForMount(view);
|
|
151
|
-
|
|
152
|
-
expect(link.getAttribute("href")).toBe("/user-export/by-job/job-123");
|
|
151
|
+
expect(view.getByTestId("privacy-export-download")).toBeTruthy();
|
|
153
152
|
const ready = view.getByTestId("privacy-export-ready");
|
|
154
153
|
expect(ready.textContent).toContain("2026-07-11");
|
|
155
154
|
expect(ready.textContent).not.toContain("T00:00");
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
useQuery,
|
|
20
20
|
useTranslation,
|
|
21
21
|
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
+
import { postWithDownload } from "@cosmicdrift/kumiko-renderer-web";
|
|
22
23
|
import { type ReactNode, useEffect, useState } from "react";
|
|
23
24
|
import {
|
|
24
25
|
EXPORT_JOB_STATUS,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
USER_ME_QUERY,
|
|
27
28
|
UserDataRightsHandlers,
|
|
28
29
|
UserDataRightsQueries,
|
|
29
|
-
userExportByJobPath,
|
|
30
30
|
} from "../constants";
|
|
31
31
|
|
|
32
32
|
const STATUS_DELETION_REQUESTED = "deletionRequested";
|
|
@@ -104,6 +104,15 @@ function ExportSection(): ReactNode {
|
|
|
104
104
|
void statusQuery.refetch?.();
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
// Download laeuft ueber den Dispatcher (traegt X-CSRF-Token) statt ueber
|
|
108
|
+
// eine <a>-Navigation: download-by-job liefert eine signed URL zurueck, auf
|
|
109
|
+
// die postWithDownload den Browser navigiert (content-disposition:
|
|
110
|
+
// attachment → laedt herunter).
|
|
111
|
+
const downloadExport = async (jobId: string): Promise<void> => {
|
|
112
|
+
const err = await postWithDownload(dispatcher, UserDataRightsQueries.downloadByJob, { jobId });
|
|
113
|
+
if (err) setStatus({ kind: "error", messageKey: failureKey(err) });
|
|
114
|
+
};
|
|
115
|
+
|
|
107
116
|
const result = statusQuery.data;
|
|
108
117
|
const job = result && result.hasJob ? result.job : null;
|
|
109
118
|
const submitting = status.kind === "submitting";
|
|
@@ -150,13 +159,15 @@ function ExportSection(): ReactNode {
|
|
|
150
159
|
})}
|
|
151
160
|
</p>
|
|
152
161
|
)}
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
<div className="mt-2">
|
|
163
|
+
<Button
|
|
164
|
+
variant="secondary"
|
|
165
|
+
onClick={() => void downloadExport(job.id)}
|
|
166
|
+
testId="privacy-export-download"
|
|
167
|
+
>
|
|
168
|
+
{t("userDataRights.privacyCenter.export.download")}
|
|
169
|
+
</Button>
|
|
170
|
+
</div>
|
|
160
171
|
</Banner>
|
|
161
172
|
)}
|
|
162
173
|
<StatusBanner status={status} />
|