@cosmicdrift/kumiko-bundled-features 0.2.2 → 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 +31 -0
- package/package.json +11 -5
- 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,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
|
+
});
|