@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,269 @@
|
|
|
1
|
+
// request-export.write + export-status.query Integration-Test (S2.U3 Atom 2).
|
|
2
|
+
//
|
|
3
|
+
// Pinst die User-Touchpoints des Async-Export-Pipeline. Atom 3b (Worker)
|
|
4
|
+
// + Atom 4b (Download) sind separate Sprints — hier nur der Trigger
|
|
5
|
+
// + das Polling.
|
|
6
|
+
//
|
|
7
|
+
// Test-Pflichten aus Plan-Doc + advisor-Review:
|
|
8
|
+
// - Happy path: User klickt → Job pending entsteht
|
|
9
|
+
// - App-side-Idempotency: 2nd Klick sieht existing → isExisting=true,
|
|
10
|
+
// KEIN neuer Job + KEIN neues Event
|
|
11
|
+
// - DB-Constraint Race-Schutz: zwischen fetchOne + crud.create
|
|
12
|
+
// erscheint ein paralleler Job (simuliert durch direct-INSERT) →
|
|
13
|
+
// 2nd Klick catched 23505 + return existing
|
|
14
|
+
// - Cross-Tenant: Alice in 2 Tenants, Tenant A click → Tenant B click
|
|
15
|
+
// sieht via App-side-Check den A-Job (DB-Constraint matcht nur auf
|
|
16
|
+
// userId, App-side-Check nutzt ctx.db.raw fuer Cross-Tenant-Lookup)
|
|
17
|
+
// - Status-Polling: User sieht eigene Jobs, Cross-User-Isolation,
|
|
18
|
+
// hasJob=false wenn nichts da
|
|
19
|
+
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
testTenantId,
|
|
26
|
+
unsafeCreateEntityTable,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
29
|
+
import { sql } from "drizzle-orm";
|
|
30
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
31
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
32
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
33
|
+
import { createUserFeature } from "../../user";
|
|
34
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
35
|
+
import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "../schema/export-job";
|
|
36
|
+
|
|
37
|
+
const REQUEST_EXPORT = "user-data-rights:write:request-export";
|
|
38
|
+
const EXPORT_STATUS = "user-data-rights:query:export-status";
|
|
39
|
+
|
|
40
|
+
let stack: TestStack;
|
|
41
|
+
|
|
42
|
+
const tenantA = testTenantId(1);
|
|
43
|
+
const tenantB = testTenantId(2);
|
|
44
|
+
const aliceUser = createTestUser({ id: 42, tenantId: tenantA, roles: ["Member"] });
|
|
45
|
+
const aliceFromB = createTestUser({ id: 42, tenantId: tenantB, roles: ["Member"] });
|
|
46
|
+
const bobUser = createTestUser({ id: 43, tenantId: tenantA, roles: ["Member"] });
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
stack = await setupTestStack({
|
|
50
|
+
features: [
|
|
51
|
+
createUserFeature(),
|
|
52
|
+
createDataRetentionFeature(),
|
|
53
|
+
createComplianceProfilesFeature(),
|
|
54
|
+
createUserDataRightsFeature(),
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
await unsafeCreateEntityTable(stack.db, exportJobEntity);
|
|
58
|
+
await createEventsTable(stack.db);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await stack.cleanup();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
beforeEach(async () => {
|
|
66
|
+
await stack.db.delete(exportJobsTable);
|
|
67
|
+
await stack.db.execute(sql`DELETE FROM kumiko_events`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
type RequestExportResponse = {
|
|
71
|
+
jobId: string;
|
|
72
|
+
status: string;
|
|
73
|
+
isExisting: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("request-export :: happy path", () => {
|
|
77
|
+
test("erster Klick erzeugt pending Job mit requestedFromTenantId=Caller-Tenant", async () => {
|
|
78
|
+
const result = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser);
|
|
79
|
+
|
|
80
|
+
expect(result.isExisting).toBe(false);
|
|
81
|
+
expect(result.status).toBe(EXPORT_JOB_STATUS.Pending);
|
|
82
|
+
expect(result.jobId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
|
83
|
+
|
|
84
|
+
const rows = await stack.db.select().from(exportJobsTable);
|
|
85
|
+
expect(rows).toHaveLength(1);
|
|
86
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle-row typing
|
|
87
|
+
expect((rows[0] as any).userId).toBe(aliceUser.id);
|
|
88
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle-row typing
|
|
89
|
+
expect((rows[0] as any).requestedFromTenantId).toBe(tenantA);
|
|
90
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle-row typing
|
|
91
|
+
expect((rows[0] as any).status).toBe(EXPORT_JOB_STATUS.Pending);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("Event 'export-job.created' im Stream", async () => {
|
|
95
|
+
await stack.http.writeOk(REQUEST_EXPORT, {}, aliceUser);
|
|
96
|
+
|
|
97
|
+
const events = await stack.db.execute(sql`
|
|
98
|
+
SELECT type FROM kumiko_events WHERE aggregate_type = 'export-job'
|
|
99
|
+
`);
|
|
100
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle-execute typing
|
|
101
|
+
const rows = ((events as any).rows ?? events) as Array<{ type: string }>;
|
|
102
|
+
expect(rows).toHaveLength(1);
|
|
103
|
+
expect(rows[0]?.type).toBe("export-job.created");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("request-export :: App-side-Idempotency (primaerer Pfad)", () => {
|
|
108
|
+
test("2nd Klick sieht existing Job → isExisting=true, KEIN neuer Job, KEIN neues Event", async () => {
|
|
109
|
+
const first = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser);
|
|
110
|
+
expect(first.isExisting).toBe(false);
|
|
111
|
+
|
|
112
|
+
const second = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser);
|
|
113
|
+
expect(second.isExisting).toBe(true);
|
|
114
|
+
expect(second.jobId).toBe(first.jobId);
|
|
115
|
+
expect(second.status).toBe(EXPORT_JOB_STATUS.Pending);
|
|
116
|
+
|
|
117
|
+
const rows = await stack.db.select().from(exportJobsTable);
|
|
118
|
+
expect(rows).toHaveLength(1);
|
|
119
|
+
|
|
120
|
+
const events = await stack.db.execute(sql`
|
|
121
|
+
SELECT type FROM kumiko_events WHERE aggregate_type = 'export-job'
|
|
122
|
+
`);
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle-execute typing
|
|
124
|
+
const evRows = ((events as any).rows ?? events) as Array<{ type: string }>;
|
|
125
|
+
expect(evRows).toHaveLength(1); // 1 Klick = 1 Event, nicht 2
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("Klick nach done-Job ist NEUER Job (Audit-Historie wird nicht blockiert)", async () => {
|
|
129
|
+
const first = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser);
|
|
130
|
+
// Worker-Simulation: status auf done flippen (direct-update OK in Test)
|
|
131
|
+
await stack.db
|
|
132
|
+
.update(exportJobsTable)
|
|
133
|
+
.set({ status: EXPORT_JOB_STATUS.Done })
|
|
134
|
+
.where(sql`id = ${first.jobId}`);
|
|
135
|
+
|
|
136
|
+
const second = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser);
|
|
137
|
+
expect(second.isExisting).toBe(false);
|
|
138
|
+
expect(second.jobId).not.toBe(first.jobId);
|
|
139
|
+
|
|
140
|
+
const rows = await stack.db.select().from(exportJobsTable);
|
|
141
|
+
expect(rows).toHaveLength(2);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("request-export :: Cross-Tenant (Plan-Doc-Pflicht-Test)", () => {
|
|
146
|
+
test("Alice klickt aus Tenant A → Tenant B Klick sieht den A-Job (kein 2. Job)", async () => {
|
|
147
|
+
const fromA = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser);
|
|
148
|
+
expect(fromA.isExisting).toBe(false);
|
|
149
|
+
|
|
150
|
+
// Alice klickt aus Tenant B (anderer executorUser.tenantId, gleicher
|
|
151
|
+
// userId). App-side-Pre-Check via ctx.db.raw findet den A-Job.
|
|
152
|
+
const fromB = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceFromB);
|
|
153
|
+
expect(fromB.isExisting).toBe(true);
|
|
154
|
+
expect(fromB.jobId).toBe(fromA.jobId);
|
|
155
|
+
|
|
156
|
+
// Genau 1 Job (kein 2. fuer Tenant B)
|
|
157
|
+
const rows = await stack.db.select().from(exportJobsTable);
|
|
158
|
+
expect(rows).toHaveLength(1);
|
|
159
|
+
// requestedFromTenantId = Tenant aus 1. Klick (= A), nicht B
|
|
160
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle-row typing
|
|
161
|
+
expect((rows[0] as any).requestedFromTenantId).toBe(tenantA);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("request-export :: Race-Schutz (Promise.all parallel)", () => {
|
|
166
|
+
test("zwei parallele Klicks → 1 Job, beide Caller sehen denselben jobId", async () => {
|
|
167
|
+
// Promise.all parallelisiert die Handler. PG-Layer macht die
|
|
168
|
+
// Serialisierung im Tx-Scheduling — einer wird crud.create-Race-
|
|
169
|
+
// Loser und nimmt den 23505-Catch-Pfad, der andere wins.
|
|
170
|
+
// Welcher konkret welchen Pfad nimmt haengt vom DB-Scheduling +
|
|
171
|
+
// App-side-Pre-Check-Timing — beide sind correctness-aequivalent.
|
|
172
|
+
// Test pinst die Invariante: 2 parallele Klicks → 1 Row, beide
|
|
173
|
+
// Caller sehen denselben jobId.
|
|
174
|
+
const [a, b] = await Promise.all([
|
|
175
|
+
stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser),
|
|
176
|
+
stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
expect(a.jobId).toBe(b.jobId);
|
|
180
|
+
// Genau einer ist isExisting=false (winner). Der andere kann via
|
|
181
|
+
// App-side-Check ODER via 23505-Race-Catch isExisting=true returnen
|
|
182
|
+
// — beides ist funktional korrekt.
|
|
183
|
+
const winners = [a, b].filter((r) => r.isExisting === false);
|
|
184
|
+
expect(winners).toHaveLength(1);
|
|
185
|
+
|
|
186
|
+
const rows = await stack.db.select().from(exportJobsTable);
|
|
187
|
+
expect(rows).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 10+ parallele Klicks bewusst NICHT getestet: das triggert event-
|
|
191
|
+
// store-stream-version-conflicts (separate Schicht ueber dem Projection-
|
|
192
|
+
// Index, Memory feedback_event_store_tenant_consistency). High-
|
|
193
|
+
// Concurrency-Race ist orthogonal zu unserem App-side+DB-Constraint-
|
|
194
|
+
// Schutz — sollte in framework/event-store-Tests gepinnt werden,
|
|
195
|
+
// nicht hier. 2-paralleler-Test reicht fuer die "Race-Schutz greift"-
|
|
196
|
+
// Invariante.
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("export-status :: User-Polling", () => {
|
|
200
|
+
type StatusResponse =
|
|
201
|
+
| { hasJob: false }
|
|
202
|
+
| {
|
|
203
|
+
hasJob: true;
|
|
204
|
+
job: {
|
|
205
|
+
id: string;
|
|
206
|
+
status: string;
|
|
207
|
+
requestedAt: string | null;
|
|
208
|
+
completedAt: string | null;
|
|
209
|
+
expiresAt: string | null;
|
|
210
|
+
errorMessage: string | null;
|
|
211
|
+
bytesWritten: number | null;
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
test("hasJob=false wenn User noch nichts requestet hat", async () => {
|
|
216
|
+
const result = await stack.http.queryOk<StatusResponse>(EXPORT_STATUS, {}, aliceUser);
|
|
217
|
+
expect(result.hasJob).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("hasJob=true mit pending-Job nach request-export", async () => {
|
|
221
|
+
await stack.http.writeOk(REQUEST_EXPORT, {}, aliceUser);
|
|
222
|
+
|
|
223
|
+
const result = await stack.http.queryOk<StatusResponse>(EXPORT_STATUS, {}, aliceUser);
|
|
224
|
+
expect(result.hasJob).toBe(true);
|
|
225
|
+
if (!result.hasJob) throw new Error("expected job");
|
|
226
|
+
expect(result.job.status).toBe(EXPORT_JOB_STATUS.Pending);
|
|
227
|
+
expect(result.job.requestedAt).not.toBeNull();
|
|
228
|
+
expect(result.job.completedAt).toBeNull();
|
|
229
|
+
expect(result.job.expiresAt).toBeNull();
|
|
230
|
+
expect(result.job.errorMessage).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("Cross-User-Isolation: Bob sieht Alice's Job NICHT", async () => {
|
|
234
|
+
await stack.http.writeOk(REQUEST_EXPORT, {}, aliceUser);
|
|
235
|
+
|
|
236
|
+
const bobResult = await stack.http.queryOk<StatusResponse>(EXPORT_STATUS, {}, bobUser);
|
|
237
|
+
expect(bobResult.hasJob).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("liefert NEUESTEN Job zurueck (orderBy desc requestedAt)", async () => {
|
|
241
|
+
const T = getTemporal();
|
|
242
|
+
// 1. Job done in der Vergangenheit
|
|
243
|
+
const oldJobId = "11111111-1111-4111-8111-111111111111";
|
|
244
|
+
await stack.db.insert(exportJobsTable).values({
|
|
245
|
+
id: oldJobId,
|
|
246
|
+
tenantId: tenantA,
|
|
247
|
+
userId: aliceUser.id,
|
|
248
|
+
requestedFromTenantId: tenantA,
|
|
249
|
+
status: EXPORT_JOB_STATUS.Done,
|
|
250
|
+
requestedAt: T.Instant.fromEpochMilliseconds(Date.now() - 60_000),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// 2. neuer pending-Job
|
|
254
|
+
const newJob = await stack.http.writeOk<RequestExportResponse>(REQUEST_EXPORT, {}, aliceUser);
|
|
255
|
+
|
|
256
|
+
const result = await stack.http.queryOk<StatusResponse>(EXPORT_STATUS, {}, aliceUser);
|
|
257
|
+
expect(result.hasJob).toBe(true);
|
|
258
|
+
if (!result.hasJob) throw new Error("expected job");
|
|
259
|
+
expect(result.job.id).toBe(newJob.jobId);
|
|
260
|
+
expect(result.job.status).toBe(EXPORT_JOB_STATUS.Pending);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("Cross-Tenant: Polling aus Tenant B sieht den Job aus Tenant A", async () => {
|
|
264
|
+
await stack.http.writeOk(REQUEST_EXPORT, {}, aliceUser);
|
|
265
|
+
|
|
266
|
+
const fromB = await stack.http.queryOk<StatusResponse>(EXPORT_STATUS, {}, aliceFromB);
|
|
267
|
+
expect(fromB.hasJob).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// S2.U6 — DSGVO Art. 18 Account-Freeze. End-to-End-Test ueber drei
|
|
2
|
+
// Features (sessions + auth-email-password + user-data-rights):
|
|
3
|
+
//
|
|
4
|
+
// - restrict-account flippt Status + revoked alle Sessions cross-feature
|
|
5
|
+
// - lift-restriction flippt zurueck
|
|
6
|
+
// - Login-Pfad blockt Restricted (eigener error code) und collapsed
|
|
7
|
+
// DeletionRequested/Deleted auf invalid_credentials (anti-enum)
|
|
8
|
+
// - Existing JWTs einer restricted-User werden via sessionChecker
|
|
9
|
+
// abgelehnt (session-revocation greift)
|
|
10
|
+
// - State-Transition-Matrix: Active↔Restricted, andere Uebergaenge
|
|
11
|
+
// fehlgeschlagen mit klaren error codes
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
testTenantId,
|
|
22
|
+
unsafeCreateEntityTable,
|
|
23
|
+
unsafePushTables,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
26
|
+
import { eq, sql } from "drizzle-orm";
|
|
27
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
28
|
+
import { AuthErrors, AuthHandlers } from "../../auth-email-password/constants";
|
|
29
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
30
|
+
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
31
|
+
import {
|
|
32
|
+
createComplianceProfilesFeature,
|
|
33
|
+
tenantComplianceProfileEntity,
|
|
34
|
+
} from "../../compliance-profiles";
|
|
35
|
+
import { createConfigFeature } from "../../config";
|
|
36
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
37
|
+
import { configValuesTable } from "../../config/table";
|
|
38
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
39
|
+
import { createSessionsFeature } from "../../sessions";
|
|
40
|
+
import { SessionHandlers } from "../../sessions/constants";
|
|
41
|
+
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
42
|
+
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
43
|
+
import { sessionCallbacksFromLateBound } from "../../sessions/testing";
|
|
44
|
+
import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
|
|
45
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
46
|
+
import { seedTenantMembership } from "../../tenant/seeding";
|
|
47
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
48
|
+
import { UserHandlers } from "../../user/constants";
|
|
49
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
50
|
+
|
|
51
|
+
const RESTRICT = "user-data-rights:write:restrict-account";
|
|
52
|
+
const LIFT = "user-data-rights:write:lift-restriction";
|
|
53
|
+
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
56
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
57
|
+
const TENANT: TenantId = testTenantId(1);
|
|
58
|
+
|
|
59
|
+
const ALICE_EMAIL = "alice.restrict@example.com";
|
|
60
|
+
const ALICE_PW = "alice-pw-long-enough";
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
64
|
+
const resolver = createConfigResolver({ encryption });
|
|
65
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
66
|
+
|
|
67
|
+
stack = await setupTestStack({
|
|
68
|
+
features: [
|
|
69
|
+
createConfigFeature(),
|
|
70
|
+
createUserFeature(),
|
|
71
|
+
createTenantFeature(),
|
|
72
|
+
createDataRetentionFeature(),
|
|
73
|
+
createComplianceProfilesFeature(),
|
|
74
|
+
createAuthEmailPasswordFeature(),
|
|
75
|
+
createSessionsFeature(),
|
|
76
|
+
createUserDataRightsFeature(),
|
|
77
|
+
],
|
|
78
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
79
|
+
authConfig: {
|
|
80
|
+
...bound.asAuthConfig(),
|
|
81
|
+
membershipQuery: "tenant:query:memberships",
|
|
82
|
+
loginHandler: AuthHandlers.login,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
callbacks.set(createSessionCallbacks({ db: stack.db }));
|
|
86
|
+
|
|
87
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
89
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
90
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
91
|
+
await createEventsTable(stack.db);
|
|
92
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
await stack.cleanup();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
beforeEach(async () => {
|
|
100
|
+
await stack.db.delete(userSessionTable);
|
|
101
|
+
await stack.db.delete(userTable);
|
|
102
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
103
|
+
await stack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
|
|
104
|
+
await stack.db.execute(sql`DELETE FROM kumiko_events`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
async function seedAliceWithMembership(
|
|
108
|
+
status: string = USER_STATUS.Active,
|
|
109
|
+
): Promise<{ userId: string }> {
|
|
110
|
+
const hash = await hashPassword(ALICE_PW);
|
|
111
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
112
|
+
UserHandlers.create,
|
|
113
|
+
{ email: ALICE_EMAIL, passwordHash: hash, displayName: "Alice" },
|
|
114
|
+
TestUsers.systemAdmin,
|
|
115
|
+
);
|
|
116
|
+
if (status !== USER_STATUS.Active) {
|
|
117
|
+
await stack.db.update(userTable).set({ status }).where(eq(userTable["id"], created.id));
|
|
118
|
+
}
|
|
119
|
+
await seedTenantMembership(stack.db, {
|
|
120
|
+
userId: created.id,
|
|
121
|
+
tenantId: TENANT,
|
|
122
|
+
roles: ["Member"],
|
|
123
|
+
});
|
|
124
|
+
return { userId: created.id };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function login(email: string, password: string): Promise<{ status: number; body: unknown }> {
|
|
128
|
+
const res = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
129
|
+
return { status: res.status, body: await res.json() };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function reasonOf(err: { details?: unknown }): string | undefined {
|
|
133
|
+
return (err.details as { reason?: string } | undefined)?.reason;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("S2.U6 :: restrict-account state-transitions", () => {
|
|
137
|
+
test("Active → Restricted: status flippt + alle Sessions revoked", async () => {
|
|
138
|
+
const { userId } = await seedAliceWithMembership();
|
|
139
|
+
// Login um eine Session zu erzeugen.
|
|
140
|
+
const loginRes = await login(ALICE_EMAIL, ALICE_PW);
|
|
141
|
+
expect(loginRes.status).toBe(200);
|
|
142
|
+
|
|
143
|
+
// Session-Row vorhanden + live (revokedAt=null).
|
|
144
|
+
const sessionsBefore = (await stack.db
|
|
145
|
+
.select({ id: userSessionTable["id"], revokedAt: userSessionTable["revokedAt"] })
|
|
146
|
+
.from(userSessionTable)
|
|
147
|
+
.where(eq(userSessionTable["userId"], userId))) as Array<{
|
|
148
|
+
id: string;
|
|
149
|
+
revokedAt: unknown;
|
|
150
|
+
}>;
|
|
151
|
+
expect(sessionsBefore.length).toBeGreaterThanOrEqual(1);
|
|
152
|
+
expect(sessionsBefore.every((s) => s.revokedAt === null)).toBe(true);
|
|
153
|
+
|
|
154
|
+
// Restrict-Account.
|
|
155
|
+
const aliceUser = {
|
|
156
|
+
id: userId,
|
|
157
|
+
tenantId: TENANT,
|
|
158
|
+
roles: ["Member"],
|
|
159
|
+
};
|
|
160
|
+
const result = await stack.http.writeOk<{ userId: string; status: string }>(
|
|
161
|
+
RESTRICT,
|
|
162
|
+
{},
|
|
163
|
+
aliceUser,
|
|
164
|
+
);
|
|
165
|
+
expect(result.status).toBe(USER_STATUS.Restricted);
|
|
166
|
+
expect(result.userId).toBe(userId);
|
|
167
|
+
|
|
168
|
+
// DB-State: status=Restricted.
|
|
169
|
+
const userRow = (await stack.db
|
|
170
|
+
.select({ status: userTable["status"] })
|
|
171
|
+
.from(userTable)
|
|
172
|
+
.where(eq(userTable["id"], userId))) as Array<{ status: string }>;
|
|
173
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Restricted);
|
|
174
|
+
|
|
175
|
+
// Alle Sessions revoked (revokedAt != null).
|
|
176
|
+
const sessionsAfter = (await stack.db
|
|
177
|
+
.select({ revokedAt: userSessionTable["revokedAt"] })
|
|
178
|
+
.from(userSessionTable)
|
|
179
|
+
.where(eq(userSessionTable["userId"], userId))) as Array<{ revokedAt: unknown }>;
|
|
180
|
+
expect(sessionsAfter.every((s) => s.revokedAt !== null)).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("Restricted → Restricted (Idempotenz-Guard): 422 already_restricted", async () => {
|
|
184
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
185
|
+
const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
|
|
186
|
+
const err = await stack.http.writeErr(RESTRICT, {}, aliceUser);
|
|
187
|
+
expect(err.httpStatus).toBe(422);
|
|
188
|
+
expect(reasonOf(err)).toBe("already_restricted");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("DeletionRequested → restrict-account: 422 user_not_in_active_state", async () => {
|
|
192
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.DeletionRequested);
|
|
193
|
+
const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
|
|
194
|
+
const err = await stack.http.writeErr(RESTRICT, {}, aliceUser);
|
|
195
|
+
expect(reasonOf(err)).toBe("user_not_in_active_state");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("S2.U6 :: lift-restriction state-transitions", () => {
|
|
200
|
+
test("Restricted → Active: status flippt zurueck", async () => {
|
|
201
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
202
|
+
const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
|
|
203
|
+
|
|
204
|
+
const result = await stack.http.writeOk<{ status: string }>(LIFT, {}, aliceUser);
|
|
205
|
+
expect(result.status).toBe(USER_STATUS.Active);
|
|
206
|
+
|
|
207
|
+
const userRow = (await stack.db
|
|
208
|
+
.select({ status: userTable["status"] })
|
|
209
|
+
.from(userTable)
|
|
210
|
+
.where(eq(userTable["id"], userId))) as Array<{ status: string }>;
|
|
211
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Active);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("Active → lift-restriction: 422 not_restricted (Idempotenz-Guard)", async () => {
|
|
215
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.Active);
|
|
216
|
+
const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
|
|
217
|
+
const err = await stack.http.writeErr(LIFT, {}, aliceUser);
|
|
218
|
+
expect(reasonOf(err)).toBe("not_restricted");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("DeletionRequested → lift-restriction: 422 not_restricted", async () => {
|
|
222
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.DeletionRequested);
|
|
223
|
+
const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
|
|
224
|
+
const err = await stack.http.writeErr(LIFT, {}, aliceUser);
|
|
225
|
+
expect(reasonOf(err)).toBe("not_restricted");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () => {
|
|
230
|
+
test("Active user login: 200 (regression check)", async () => {
|
|
231
|
+
await seedAliceWithMembership(USER_STATUS.Active);
|
|
232
|
+
const res = await login(ALICE_EMAIL, ALICE_PW);
|
|
233
|
+
expect(res.status).toBe(200);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("Restricted user login: 422 account_restricted (eigener Code, kein collapse)", async () => {
|
|
237
|
+
await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
238
|
+
const res = await login(ALICE_EMAIL, ALICE_PW);
|
|
239
|
+
expect(res.status).toBe(422);
|
|
240
|
+
const body = res.body as { error?: { details?: { reason?: string } } };
|
|
241
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.accountRestricted);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("DeletionRequested user login: 422 invalid_credentials (anti-enum collapse)", async () => {
|
|
245
|
+
await seedAliceWithMembership(USER_STATUS.DeletionRequested);
|
|
246
|
+
const res = await login(ALICE_EMAIL, ALICE_PW);
|
|
247
|
+
expect(res.status).toBe(422);
|
|
248
|
+
const body = res.body as { error?: { details?: { reason?: string } } };
|
|
249
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("Deleted user login: 422 invalid_credentials (anti-enum collapse)", async () => {
|
|
253
|
+
await seedAliceWithMembership(USER_STATUS.Deleted);
|
|
254
|
+
const res = await login(ALICE_EMAIL, ALICE_PW);
|
|
255
|
+
expect(res.status).toBe(422);
|
|
256
|
+
const body = res.body as { error?: { details?: { reason?: string } } };
|
|
257
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("Wrong password vor Status-Check (timing-equivalence): 422 invalid_credentials, NICHT account_restricted", async () => {
|
|
261
|
+
// Restricted User mit FALSCHEM Passwort darf NICHT account_restricted
|
|
262
|
+
// erfahren — invalid_credentials kommt zuerst (siehe login.write.ts:
|
|
263
|
+
// Status-Check ist NACH password-verify). Sonst koennte ein Angreifer
|
|
264
|
+
// ohne valid Credentials den Restricted-Status enumerieren.
|
|
265
|
+
await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
266
|
+
const res = await login(ALICE_EMAIL, "wrong-password-here");
|
|
267
|
+
expect(res.status).toBe(422);
|
|
268
|
+
const body = res.body as { error?: { details?: { reason?: string } } };
|
|
269
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
|
|
274
|
+
test("Privileged-Caller revoked alle live sessions eines Users", async () => {
|
|
275
|
+
const { userId } = await seedAliceWithMembership();
|
|
276
|
+
// 2 Sessions erzeugen via Login + zweiter Login.
|
|
277
|
+
const a = await login(ALICE_EMAIL, ALICE_PW);
|
|
278
|
+
const b = await login(ALICE_EMAIL, ALICE_PW);
|
|
279
|
+
expect(a.status).toBe(200);
|
|
280
|
+
expect(b.status).toBe(200);
|
|
281
|
+
|
|
282
|
+
const liveBefore = (await stack.db
|
|
283
|
+
.select({ id: userSessionTable["id"] })
|
|
284
|
+
.from(userSessionTable)
|
|
285
|
+
.where(eq(userSessionTable["userId"], userId))) as Array<{ id: string }>;
|
|
286
|
+
expect(liveBefore.length).toBe(2);
|
|
287
|
+
|
|
288
|
+
// System-Caller.
|
|
289
|
+
const systemUser = {
|
|
290
|
+
id: "00000000-0000-4000-8000-000000000000",
|
|
291
|
+
tenantId: TENANT,
|
|
292
|
+
roles: ["SystemAdmin"],
|
|
293
|
+
};
|
|
294
|
+
const result = await stack.http.writeOk<{ count: number; userId: string }>(
|
|
295
|
+
SessionHandlers.revokeAllForUser,
|
|
296
|
+
{ userId },
|
|
297
|
+
systemUser,
|
|
298
|
+
);
|
|
299
|
+
expect(result.count).toBe(2);
|
|
300
|
+
expect(result.userId).toBe(userId);
|
|
301
|
+
|
|
302
|
+
// Alle revoked.
|
|
303
|
+
const revoked = (await stack.db
|
|
304
|
+
.select({ revokedAt: userSessionTable["revokedAt"] })
|
|
305
|
+
.from(userSessionTable)
|
|
306
|
+
.where(eq(userSessionTable["userId"], userId))) as Array<{ revokedAt: unknown }>;
|
|
307
|
+
expect(revoked.every((s) => s.revokedAt !== null)).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
});
|