@cosmicdrift/kumiko-bundled-features 0.105.1 → 0.105.2
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/auth-email-password/__tests__/invite-flow.integration.test.ts +112 -2
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +8 -2
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +76 -1
- package/src/folders/__tests__/folders.integration.test.ts +55 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +62 -0
- package/src/folders/web/folder-manager.tsx +9 -2
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +10 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +16 -0
- package/src/user/__tests__/user.integration.test.ts +36 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +44 -2
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +2 -2
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +61 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +42 -0
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/screens.ts +7 -6
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +40 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.105.
|
|
3
|
+
"version": "0.105.2",
|
|
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>",
|
|
@@ -93,11 +93,11 @@
|
|
|
93
93
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.105.
|
|
97
|
-
"@cosmicdrift/kumiko-framework": "0.105.
|
|
98
|
-
"@cosmicdrift/kumiko-headless": "0.105.
|
|
99
|
-
"@cosmicdrift/kumiko-renderer": "0.105.
|
|
100
|
-
"@cosmicdrift/kumiko-renderer-web": "0.105.
|
|
96
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.105.2",
|
|
97
|
+
"@cosmicdrift/kumiko-framework": "0.105.2",
|
|
98
|
+
"@cosmicdrift/kumiko-headless": "0.105.2",
|
|
99
|
+
"@cosmicdrift/kumiko-renderer": "0.105.2",
|
|
100
|
+
"@cosmicdrift/kumiko-renderer-web": "0.105.2",
|
|
101
101
|
"@mollie/api-client": "^4.5.0",
|
|
102
102
|
"@node-rs/argon2": "^2.0.2",
|
|
103
103
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// 5. DB-State + Membership + Cookies/JWT verifizieren
|
|
16
16
|
|
|
17
17
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
-
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
18
|
+
import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
19
|
import {
|
|
20
20
|
createSystemUser,
|
|
21
21
|
type SessionUser,
|
|
@@ -38,7 +38,11 @@ import { createRendererFoundationFeature } from "../../renderer-foundation/featu
|
|
|
38
38
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
39
39
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
40
40
|
import { createTenantFeature } from "../../tenant";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
INVITATION_STATUS,
|
|
43
|
+
tenantInvitationEntity,
|
|
44
|
+
tenantInvitationsTable,
|
|
45
|
+
} from "../../tenant/invitation-table";
|
|
42
46
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
43
47
|
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
44
48
|
import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
|
|
@@ -46,6 +50,7 @@ import { createUserFeature } from "../../user/feature";
|
|
|
46
50
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
47
51
|
import { AuthErrors, AuthHandlers } from "../constants";
|
|
48
52
|
import { createAuthEmailPasswordFeature } from "../feature";
|
|
53
|
+
import { storeInviteToken } from "../invite-token-store";
|
|
49
54
|
import { hashPassword } from "../password-hashing";
|
|
50
55
|
import { seedUser } from "../seeding";
|
|
51
56
|
|
|
@@ -254,6 +259,12 @@ describe("invite-create", () => {
|
|
|
254
259
|
const rows = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
|
|
255
260
|
expect(rows).toHaveLength(1);
|
|
256
261
|
expect(rows[0]?.["role"]).toBe("Editor");
|
|
262
|
+
|
|
263
|
+
// inviteEmail() only reads emailTransport.sent.at(-1) — a silent second-
|
|
264
|
+
// dispatch failure would still leave sent.length===1 and .at(-1) pointing
|
|
265
|
+
// at the FIRST mail, making secondToken===firstToken pass for the wrong
|
|
266
|
+
// reason. Assert an actual second dispatch happened.
|
|
267
|
+
expect(emailTransport.sent).toHaveLength(2);
|
|
257
268
|
});
|
|
258
269
|
});
|
|
259
270
|
|
|
@@ -403,6 +414,105 @@ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
|
|
|
403
414
|
});
|
|
404
415
|
});
|
|
405
416
|
|
|
417
|
+
describe("invite-accept defense-in-depth (assertAssignableMembershipRoles)", () => {
|
|
418
|
+
test("a forbidden role planted directly on the invitation row (bug/migration) is rejected, not silently granted", async () => {
|
|
419
|
+
// Simulates the ONE scenario this depth-layer exists for: a forbidden role
|
|
420
|
+
// reaching the invitation row through some path OTHER than invite-create
|
|
421
|
+
// (which already validates at request time — see "privilege escalation"
|
|
422
|
+
// below). A DB migration or direct write is the only realistic vector.
|
|
423
|
+
const fakeInvitationId = crypto.randomUUID();
|
|
424
|
+
await insertOne(stack.db, tenantInvitationsTable, {
|
|
425
|
+
id: fakeInvitationId,
|
|
426
|
+
tenantId: TENANT_A_ID,
|
|
427
|
+
email: BOB_EMAIL,
|
|
428
|
+
role: "SystemAdmin",
|
|
429
|
+
status: INVITATION_STATUS.pending,
|
|
430
|
+
invitedBy: aliceId,
|
|
431
|
+
expiresAt: "2030-01-01T00:00:00Z",
|
|
432
|
+
});
|
|
433
|
+
const token = crypto.randomUUID();
|
|
434
|
+
await storeInviteToken(stack.redis.redis, {
|
|
435
|
+
invitationId: fakeInvitationId,
|
|
436
|
+
token,
|
|
437
|
+
ttlSeconds: 3600,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
|
|
441
|
+
expect(res.status).toBe(403);
|
|
442
|
+
const body = (await res.json()) as { error?: { code?: string } };
|
|
443
|
+
expect(body.error?.code).toBe("access_denied");
|
|
444
|
+
|
|
445
|
+
// No membership was granted.
|
|
446
|
+
const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
|
|
447
|
+
expect(memberships).toHaveLength(1); // only the seeded Tenant-B membership
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe("invite-accept-with-login/signup-complete defense-in-depth (637/1)", () => {
|
|
452
|
+
// Branch 1 (invite-accept, logged-in) and Branch 3 (invite-signup-complete)
|
|
453
|
+
// both route every new membership through seedTenantMembership, which
|
|
454
|
+
// calls assertAssignableMembershipRoles unconditionally (tenant/seeding.ts)
|
|
455
|
+
// — a forbidden invitation role is rejected outright, 403, before it ever
|
|
456
|
+
// reaches stripForbiddenMembershipRoles. Branch 2 (invite-accept-with-login)
|
|
457
|
+
// is different: it skips seedTenantMembership entirely when the user is
|
|
458
|
+
// ALREADY a member of the invited tenant (idempotent-accept path) — for
|
|
459
|
+
// that one case, stripForbiddenMembershipRoles at the session mint is the
|
|
460
|
+
// ONLY protection. Removing that call would silently mint a
|
|
461
|
+
// SystemAdmin-carrying session with no other test catching it.
|
|
462
|
+
test("invite-accept-with-login: already-member path strips a forbidden invitation role (seedTenantMembership's guard is skipped here)", async () => {
|
|
463
|
+
// Bob already has a (legitimate) membership in TENANT_A_ID — the
|
|
464
|
+
// handler's alreadyMember check short-circuits before seedTenantMembership,
|
|
465
|
+
// so assertAssignableMembershipRoles never runs for this accept.
|
|
466
|
+
await seedTenantMembership(stack.db, {
|
|
467
|
+
userId: bobId,
|
|
468
|
+
tenantId: TENANT_A_ID,
|
|
469
|
+
roles: ["User"],
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Create through the real command (valid role → real stream + version),
|
|
473
|
+
// then corrupt the row directly — the migration/DB-surgery scenario this
|
|
474
|
+
// depth-layer guards against, same framing as the Branch-1 test above.
|
|
475
|
+
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
476
|
+
await asRawClient(stack.db).unsafe(
|
|
477
|
+
`UPDATE "${tenantInvitationsTable.tableName}" SET "role" = 'SystemAdmin' WHERE "email" = $1`,
|
|
478
|
+
[BOB_EMAIL],
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
482
|
+
token,
|
|
483
|
+
email: BOB_EMAIL,
|
|
484
|
+
password: BOB_PASSWORD,
|
|
485
|
+
});
|
|
486
|
+
expect(res.status).toBe(200);
|
|
487
|
+
const body = (await res.json()) as { user: { roles: string[] } };
|
|
488
|
+
expect(body.user.roles).toEqual([]);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("invite-signup-complete: a brand-new user always hits seedTenantMembership's guard — forbidden role rejected, not silently stripped", async () => {
|
|
492
|
+
// Unlike Branch 2, a brand-new user can never be "already a member" —
|
|
493
|
+
// seedTenantMembership (and its assertAssignableMembershipRoles guard)
|
|
494
|
+
// runs unconditionally. Pins that this branch is NOT exposed to the
|
|
495
|
+
// Branch-2 gap, so a future refactor that adds an alreadyMember-style
|
|
496
|
+
// skip here would be caught immediately.
|
|
497
|
+
const daveEmail = "dave@example.com";
|
|
498
|
+
const token = await inviteEmail(daveEmail, "Admin");
|
|
499
|
+
await asRawClient(stack.db).unsafe(
|
|
500
|
+
`UPDATE "${tenantInvitationsTable.tableName}" SET "role" = 'SystemAdmin' WHERE "email" = $1`,
|
|
501
|
+
[daveEmail],
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
505
|
+
token,
|
|
506
|
+
password: "dave-new-pw-1234",
|
|
507
|
+
});
|
|
508
|
+
expect(res.status).toBe(403);
|
|
509
|
+
|
|
510
|
+
// No user or membership was created — the whole write rolled back.
|
|
511
|
+
const daveRows = await selectMany(stack.db, userTable, { email: daveEmail });
|
|
512
|
+
expect(daveRows).toHaveLength(0);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
406
516
|
describe("Single-Use-Burn (alle Branches)", () => {
|
|
407
517
|
test("Branch 1: zweiter accept mit gleichem Token → invalid", async () => {
|
|
408
518
|
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
@@ -300,11 +300,17 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
|
|
|
300
300
|
);
|
|
301
301
|
|
|
302
302
|
const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
|
|
303
|
+
const saveBtn = screen.getByTestId("custom-fields-form-save") as HTMLButtonElement;
|
|
303
304
|
// Tippen + zurück auf den Bestandswert → nicht dirty, kein Write.
|
|
304
305
|
await user.type(vendorInput, "2");
|
|
305
306
|
await user.type(vendorInput, "{Backspace}");
|
|
306
|
-
|
|
307
|
-
|
|
307
|
+
// Positiver Anker: erst abwarten, dass der Save-Button wieder disabled ist
|
|
308
|
+
// (beweist, dass die dirty-Neuberechnung durchgelaufen ist), statt direkt
|
|
309
|
+
// eine negative Assertion in waitFor zu pollen (die beim ersten Tick
|
|
310
|
+
// trivial besteht und keine ausstehenden Async-Effekte abwartet).
|
|
311
|
+
await waitFor(() => expect(saveBtn.disabled).toBe(true));
|
|
312
|
+
await user.click(saveBtn);
|
|
313
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
308
314
|
});
|
|
309
315
|
});
|
|
310
316
|
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
17
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
18
|
import {
|
|
18
19
|
setupTestStack,
|
|
19
20
|
type TestStack,
|
|
20
21
|
unsafeCreateEntityTable,
|
|
21
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
24
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
23
25
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
|
|
24
26
|
import { runRetentionCleanup } from "../run-retention-cleanup";
|
|
@@ -52,13 +54,37 @@ const staleEntity = createEntity({
|
|
|
52
54
|
retention: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
53
55
|
});
|
|
54
56
|
|
|
57
|
+
// blockDelete = Aufbewahrungspflicht (HGB/DSGVO-Presets: invoice/booking/
|
|
58
|
+
// contract). Der Cleanup-Job muss diese Rows IGNORIEREN — nur user-forget
|
|
59
|
+
// darf sie anonymisieren. Ein Regress im switch-Default oder im blockDelete-
|
|
60
|
+
// Case, der sie trotzdem löscht, wäre schwerwiegender als ein verpasstes
|
|
61
|
+
// hardDelete (Aufbewahrungspflicht-Verletzung statt nur "zu spät aufgeräumt").
|
|
62
|
+
const retainedEntity = createEntity({
|
|
63
|
+
table: "read_c7_retained",
|
|
64
|
+
fields: {
|
|
65
|
+
label: createTextField({ required: true, anonymize: () => "[ANONYMIZED]" }),
|
|
66
|
+
},
|
|
67
|
+
retention: { keepFor: "30d", strategy: "blockDelete" },
|
|
68
|
+
});
|
|
69
|
+
|
|
55
70
|
const c7Feature = defineFeature("c7-retention-fixtures", (r) => {
|
|
56
71
|
r.entity("c7-widget", widgetEntity);
|
|
57
72
|
r.entity("c7-gadget", gadgetEntity);
|
|
58
73
|
r.entity("c7-plain", plainEntity);
|
|
59
74
|
r.entity("c7-stale", staleEntity);
|
|
75
|
+
r.entity("c7-retained", retainedEntity);
|
|
60
76
|
});
|
|
61
77
|
|
|
78
|
+
const noopLogger: JobContext["log"] = {
|
|
79
|
+
info() {},
|
|
80
|
+
warn() {},
|
|
81
|
+
error() {},
|
|
82
|
+
debug() {},
|
|
83
|
+
child() {
|
|
84
|
+
return noopLogger;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
62
88
|
const T1 = "11111111-1111-1111-1111-111111111111";
|
|
63
89
|
const T2 = "22222222-2222-2222-2222-222222222222";
|
|
64
90
|
|
|
@@ -75,6 +101,7 @@ beforeAll(async () => {
|
|
|
75
101
|
gadgetEntity,
|
|
76
102
|
plainEntity,
|
|
77
103
|
staleEntity,
|
|
104
|
+
retainedEntity,
|
|
78
105
|
]) {
|
|
79
106
|
await unsafeCreateEntityTable(stack.db, e);
|
|
80
107
|
}
|
|
@@ -111,7 +138,13 @@ async function liveGadgetLabels(tenantId: string): Promise<string[]> {
|
|
|
111
138
|
}
|
|
112
139
|
|
|
113
140
|
beforeEach(async () => {
|
|
114
|
-
for (const t of [
|
|
141
|
+
for (const t of [
|
|
142
|
+
"read_c7_widget",
|
|
143
|
+
"read_c7_gadget",
|
|
144
|
+
"read_c7_plain",
|
|
145
|
+
"read_c7_stale",
|
|
146
|
+
"read_c7_retained",
|
|
147
|
+
]) {
|
|
115
148
|
await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
|
|
116
149
|
}
|
|
117
150
|
});
|
|
@@ -185,4 +218,46 @@ describe("runRetentionCleanup :: real postgres", () => {
|
|
|
185
218
|
});
|
|
186
219
|
expect(result.hardDeleted).toBe(0);
|
|
187
220
|
});
|
|
221
|
+
|
|
222
|
+
test("the registered job handler resolves ctx.systemUser.tenantId and actually cleans up (not runRetentionCleanup called directly)", async () => {
|
|
223
|
+
// cleanup-cron-registration.test.ts only pins job metadata; runRetentionCleanup
|
|
224
|
+
// tests above call it directly, bypassing the handler body entirely — a bug in
|
|
225
|
+
// the ctx.systemUser?.tenantId ?? ctx._tenantId resolution or the throw-guard
|
|
226
|
+
// would be invisible to both. Invoke the ACTUAL registered handler.
|
|
227
|
+
await seed("read_c7_widget", T1, "expired-via-handler", pastIso);
|
|
228
|
+
await seed("read_c7_widget", T1, "fresh-via-handler", withinIso);
|
|
229
|
+
|
|
230
|
+
const job = stack.registry.getJob("data-retention:job:retention-cleanup");
|
|
231
|
+
expect(job).toBeDefined();
|
|
232
|
+
if (!job) return;
|
|
233
|
+
|
|
234
|
+
const ctx: JobContext = {
|
|
235
|
+
db: stack.db,
|
|
236
|
+
registry: stack.registry,
|
|
237
|
+
systemUser: { id: "system", tenantId: T1, roles: ["all"] },
|
|
238
|
+
log: noopLogger,
|
|
239
|
+
triggeredBy: null,
|
|
240
|
+
...bridgeStub(),
|
|
241
|
+
};
|
|
242
|
+
await job.handler({}, ctx);
|
|
243
|
+
|
|
244
|
+
expect(await labels("read_c7_widget", T1)).toEqual(["fresh-via-handler"]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("blockDelete: past-cutoff rows are never touched by the cleanup job", async () => {
|
|
248
|
+
await seed("read_c7_retained", T1, "should-survive", pastIso);
|
|
249
|
+
|
|
250
|
+
const result = await runRetentionCleanup({
|
|
251
|
+
db: stack.db,
|
|
252
|
+
registry: stack.registry,
|
|
253
|
+
tenantId: T1,
|
|
254
|
+
tenantPreset: null,
|
|
255
|
+
now,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(await labels("read_c7_retained", T1)).toEqual(["should-survive"]);
|
|
259
|
+
expect(result.hardDeleted).toBe(0);
|
|
260
|
+
expect(result.softDeleted).toBe(0);
|
|
261
|
+
expect(result.anonymizeDeferred).not.toContain("c7-retained");
|
|
262
|
+
});
|
|
188
263
|
});
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type TestStack,
|
|
19
19
|
unsafeCreateEntityTable,
|
|
20
20
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { folderAssignmentDeleteHook, folderDeleteHook } from "../../folders-user-data/hooks";
|
|
21
22
|
import { FoldersHandlers, FoldersQueries } from "../constants";
|
|
22
23
|
import { folderAssignmentEntity, folderEntity } from "../entity";
|
|
23
24
|
import { createFoldersFeature } from "../feature";
|
|
@@ -288,3 +289,57 @@ describe("folders integration — openToAll access model", () => {
|
|
|
288
289
|
expect(denied.httpStatus).toBe(403);
|
|
289
290
|
});
|
|
290
291
|
});
|
|
292
|
+
|
|
293
|
+
// GDPR Art. 17 forget: the folder tables are tenant-scoped (no per-user owner
|
|
294
|
+
// column), so per-user erasure via these hooks is only safe when the tenant
|
|
295
|
+
// is effectively single-user. A wrong/missing guard here would delete
|
|
296
|
+
// co-members' folders on a shared tenant — exercise the hooks directly
|
|
297
|
+
// (not a synthetic stand-in) against real seeded rows.
|
|
298
|
+
describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
299
|
+
async function seedOneFolderWithAssignment(): Promise<void> {
|
|
300
|
+
const f = await createFolder("to-be-erased");
|
|
301
|
+
await setFolder(f, "credit-erase");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
test("multi-user tenant: no-op, rows survive", async () => {
|
|
305
|
+
await seedOneFolderWithAssignment();
|
|
306
|
+
const ctx = {
|
|
307
|
+
db: stack.db,
|
|
308
|
+
tenantId: admin.tenantId,
|
|
309
|
+
userId: admin.id,
|
|
310
|
+
tenantModel: "multi-user" as const,
|
|
311
|
+
};
|
|
312
|
+
await folderDeleteHook(ctx, "delete");
|
|
313
|
+
await folderAssignmentDeleteHook(ctx, "delete");
|
|
314
|
+
expect(await listFolders()).toHaveLength(1);
|
|
315
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("anonymize strategy: no-op even on a single-user tenant", async () => {
|
|
319
|
+
await seedOneFolderWithAssignment();
|
|
320
|
+
const ctx = {
|
|
321
|
+
db: stack.db,
|
|
322
|
+
tenantId: admin.tenantId,
|
|
323
|
+
userId: admin.id,
|
|
324
|
+
tenantModel: "single-user" as const,
|
|
325
|
+
};
|
|
326
|
+
await folderDeleteHook(ctx, "anonymize");
|
|
327
|
+
await folderAssignmentDeleteHook(ctx, "anonymize");
|
|
328
|
+
expect(await listFolders()).toHaveLength(1);
|
|
329
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("single-user tenant + delete: rows are purged", async () => {
|
|
333
|
+
await seedOneFolderWithAssignment();
|
|
334
|
+
const ctx = {
|
|
335
|
+
db: stack.db,
|
|
336
|
+
tenantId: admin.tenantId,
|
|
337
|
+
userId: admin.id,
|
|
338
|
+
tenantModel: "single-user" as const,
|
|
339
|
+
};
|
|
340
|
+
await folderDeleteHook(ctx, "delete");
|
|
341
|
+
await folderAssignmentDeleteHook(ctx, "delete");
|
|
342
|
+
expect(await listFolders()).toHaveLength(0);
|
|
343
|
+
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -203,5 +203,67 @@ describe("FolderManager filing mode", () => {
|
|
|
203
203
|
entityId: "c-1",
|
|
204
204
|
}),
|
|
205
205
|
);
|
|
206
|
+
|
|
207
|
+
// Dropping the per-leaf-override entity onto the unfiled bucket clears it
|
|
208
|
+
// with ITS OWN entityType, not the tree default — same override/fallback
|
|
209
|
+
// resolution the folder-drop above proved, now exercised on clearFolder.
|
|
210
|
+
dropLeaf("folder-node-unfiled", "b-1");
|
|
211
|
+
await waitFor(() =>
|
|
212
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.clearFolder, {
|
|
213
|
+
entityType: "bauspar",
|
|
214
|
+
entityId: "b-1",
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("dragLeave onto a child element keeps the highlight; leaving the row entirely clears it", () => {
|
|
220
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
221
|
+
render(
|
|
222
|
+
<Wrapper>
|
|
223
|
+
<FolderManager filing={filingWith(() => {})} />
|
|
224
|
+
</Wrapper>,
|
|
225
|
+
);
|
|
226
|
+
const row = screen.getByTestId("folder-node-f1");
|
|
227
|
+
const childButton = screen.getByTestId("folder-delete-f1");
|
|
228
|
+
|
|
229
|
+
fireEvent.dragOver(row, { dataTransfer: {} });
|
|
230
|
+
expect(row.className).toContain("ring-primary/40");
|
|
231
|
+
|
|
232
|
+
// jsdom/happy-dom's DragEvent constructor ignores `relatedTarget` in the
|
|
233
|
+
// init dict (unlike real browsers), so it has to be forced on via
|
|
234
|
+
// defineProperty rather than passed through fireEvent.dragLeave(el, init).
|
|
235
|
+
const leaveTo = (relatedTarget: Element): void => {
|
|
236
|
+
const evt = new DragEvent("dragleave", { bubbles: true, cancelable: true });
|
|
237
|
+
Object.defineProperty(evt, "relatedTarget", { value: relatedTarget, configurable: true });
|
|
238
|
+
fireEvent(row, evt);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Regression #671/3: bubbling from a child (chevron/icon/label) must NOT
|
|
242
|
+
// clear the highlight — onDragOver re-sets it a micro-task later anyway,
|
|
243
|
+
// so an unconditional clear here just flickers.
|
|
244
|
+
leaveTo(childButton);
|
|
245
|
+
expect(row.className).toContain("ring-primary/40");
|
|
246
|
+
|
|
247
|
+
leaveTo(document.body);
|
|
248
|
+
expect(row.className).not.toContain("ring-primary/40");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("the unfiled bucket stays a drop target even when nothing is currently unfiled", () => {
|
|
252
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
253
|
+
const allFiled: FolderFiling = {
|
|
254
|
+
entityType: "credit",
|
|
255
|
+
leavesByFolder: new Map([["f1", [{ id: "c-1", label: "Credit 1" }]]]),
|
|
256
|
+
unfiled: [],
|
|
257
|
+
unfiledLabel: "Unfiled",
|
|
258
|
+
onReassigned: () => {},
|
|
259
|
+
};
|
|
260
|
+
render(
|
|
261
|
+
<Wrapper>
|
|
262
|
+
<FolderManager filing={allFiled} />
|
|
263
|
+
</Wrapper>,
|
|
264
|
+
);
|
|
265
|
+
// Regression #671/5: an empty `unfiled` array must not remove the bucket —
|
|
266
|
+
// it's the only drop target to un-file a leaf back out of its folder.
|
|
267
|
+
expect(screen.getByTestId("folder-node-unfiled")).toBeTruthy();
|
|
206
268
|
});
|
|
207
269
|
});
|
|
@@ -267,7 +267,10 @@ export function FolderManager({
|
|
|
267
267
|
e.dataTransfer.dropEffect = "move";
|
|
268
268
|
setDragOverKey(key);
|
|
269
269
|
},
|
|
270
|
-
onDragLeave: (
|
|
270
|
+
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
|
|
271
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node))
|
|
272
|
+
setDragOverKey((cur) => (cur === key ? null : cur));
|
|
273
|
+
},
|
|
271
274
|
onDrop: (e: DragEvent<HTMLDivElement>) => {
|
|
272
275
|
e.preventDefault();
|
|
273
276
|
setDragOverKey(null);
|
|
@@ -509,7 +512,11 @@ export function FolderManager({
|
|
|
509
512
|
};
|
|
510
513
|
walk(tree, 0);
|
|
511
514
|
|
|
512
|
-
|
|
515
|
+
// The bucket itself must stay in the tree even when nothing is currently
|
|
516
|
+
// unfiled — it's the only drop target to un-file a leaf back out of a
|
|
517
|
+
// folder. Regression: #671/5 — an empty `unfiled` array made the bucket
|
|
518
|
+
// vanish entirely, silently removing that escape hatch.
|
|
519
|
+
const hasUnfiled = filing !== undefined;
|
|
513
520
|
if (hasUnfiled) {
|
|
514
521
|
out.push(bucketRow(stripeNext()));
|
|
515
522
|
if (!collapsed.has(UNFILED))
|
|
@@ -160,6 +160,16 @@ describe("managed-pages :: Cache + Security-Header", () => {
|
|
|
160
160
|
});
|
|
161
161
|
expect(second.status).toBe(304);
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
test("HEAD → 200 without body, etag present", async () => {
|
|
165
|
+
// legal-pages got this HEAD-early-exit test alongside its identical
|
|
166
|
+
// Cache/Header pattern; managed-pages received the same HEAD codepath
|
|
167
|
+
// in the same PR but not the test.
|
|
168
|
+
const res = await stack.app.request("http://a.example.com/p/about", { method: "HEAD" });
|
|
169
|
+
expect(res.status).toBe(200);
|
|
170
|
+
expect(await res.text()).toBe("");
|
|
171
|
+
expect(res.headers.get("etag")).toBeTruthy();
|
|
172
|
+
});
|
|
163
173
|
});
|
|
164
174
|
|
|
165
175
|
describe("managed-pages :: XSS-Härtung", () => {
|
|
@@ -500,6 +500,22 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
|
|
|
500
500
|
expect(body.error?.details?.reason).toBe("blocked");
|
|
501
501
|
});
|
|
502
502
|
|
|
503
|
+
test("fail-open invariant: read_users row hard-gone (not soft-deleted) → session stays live", async () => {
|
|
504
|
+
// The defense-in-depth read_users lookup must fail OPEN on a miss —
|
|
505
|
+
// revocation is the primary control. If `if (user && ...)` ever regressed
|
|
506
|
+
// to `if (!user || ...)`, a missing user row would turn this second layer
|
|
507
|
+
// into a global lockout for every affected session instead of a no-op.
|
|
508
|
+
const { userId } = await h.seedUser("hardgone@example.com", "pw-long-enough");
|
|
509
|
+
const { token } = await h.login("hardgone@example.com", "pw-long-enough");
|
|
510
|
+
await deleteMany(stack.db, userTable, { id: userId });
|
|
511
|
+
|
|
512
|
+
const res = await h.authedPost("/api/query", token, {
|
|
513
|
+
type: "user:query:user:me",
|
|
514
|
+
payload: {},
|
|
515
|
+
});
|
|
516
|
+
expect(res.status).toBe(200);
|
|
517
|
+
});
|
|
518
|
+
|
|
503
519
|
test("deletionRequested keeps its session live — reversible grace period", async () => {
|
|
504
520
|
const { userId } = await h.seedUser("leaving@example.com", "pw-long-enough");
|
|
505
521
|
const { token } = await h.login("leaving@example.com", "pw-long-enough");
|
|
@@ -197,3 +197,39 @@ describe("scenario 4: detail + list access", () => {
|
|
|
197
197
|
expect(res.status).toBe(403);
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
// --- Scenario 8 (575/1): entityEdit round-trip via convention QNs ---
|
|
202
|
+
//
|
|
203
|
+
// The boot-validator does not check that an entityEdit has a matching
|
|
204
|
+
// update/detail handler — the tenant feature closes this gap with its own
|
|
205
|
+
// scenario 8 (create -> detail -> update -> reload -> assert); user had no
|
|
206
|
+
// equivalent. Scenarios 3/4 exercise update and detail separately (update
|
|
207
|
+
// verified via the `me` query, detail verified as a read-only fetch) but
|
|
208
|
+
// never chain detail -> update -> detail the way the entityEdit screen
|
|
209
|
+
// actually drives its data — this is that missing round-trip proof.
|
|
210
|
+
|
|
211
|
+
describe("scenario 8: entityEdit round-trip via convention QNs", () => {
|
|
212
|
+
test("user:query:user:detail + user:write:user:update round-trip (entityEdit save persists)", async () => {
|
|
213
|
+
const created = await seedUser({ email: "roundtrip@example.com", displayName: "Before" });
|
|
214
|
+
|
|
215
|
+
const loaded = await stack.http.queryOk<Record<string, unknown>>(
|
|
216
|
+
UserQueries.detail,
|
|
217
|
+
{ id: created.id },
|
|
218
|
+
systemAdmin,
|
|
219
|
+
);
|
|
220
|
+
expect(loaded["displayName"]).toBe("Before");
|
|
221
|
+
|
|
222
|
+
await stack.http.writeOk(
|
|
223
|
+
UserHandlers.update,
|
|
224
|
+
{ id: created.id, changes: { displayName: "After" }, version: loaded["version"] },
|
|
225
|
+
systemAdmin,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const reloaded = await stack.http.queryOk<Record<string, unknown>>(
|
|
229
|
+
UserQueries.detail,
|
|
230
|
+
{ id: created.id },
|
|
231
|
+
systemAdmin,
|
|
232
|
+
);
|
|
233
|
+
expect(reloaded["displayName"]).toBe("After");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
10
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
-
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { type JobContext, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import {
|
|
13
13
|
createInMemoryFileProvider,
|
|
14
14
|
type FileStorageProvider,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
unsafeCreateEntityTable,
|
|
22
22
|
unsafePushTables,
|
|
23
23
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
-
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
|
+
import { bridgeStub, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
25
25
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
26
26
|
import { createConfigFeature } from "../../config";
|
|
27
27
|
import { createConfigAccessorFactory } from "../../config/feature";
|
|
@@ -53,6 +53,7 @@ let seed: ForgetSeeders;
|
|
|
53
53
|
// Per-tenant resolver the forget pipeline uses — built from the stack's
|
|
54
54
|
// configResolver, resolves "test" → the test provider plugin → `provider`.
|
|
55
55
|
let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
|
|
56
|
+
let configResolverForJobCtx: ReturnType<typeof createConfigResolver>;
|
|
56
57
|
|
|
57
58
|
const TENANT = "00000000-0000-4000-8000-00000000000c";
|
|
58
59
|
|
|
@@ -67,6 +68,7 @@ beforeAll(async () => {
|
|
|
67
68
|
// via a config app-override (no admin write needed).
|
|
68
69
|
const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
|
|
69
70
|
const resolver = createConfigResolver({ appOverrides });
|
|
71
|
+
configResolverForJobCtx = resolver;
|
|
70
72
|
stack = await setupTestStack({
|
|
71
73
|
features: [
|
|
72
74
|
createConfigFeature(),
|
|
@@ -176,3 +178,43 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
|
|
|
176
178
|
expect(await provider.exists(otherKey)).toBe(true);
|
|
177
179
|
});
|
|
178
180
|
});
|
|
181
|
+
|
|
182
|
+
describe("run-forget-cleanup :: registered cron actually erases binaries (not just row-only)", () => {
|
|
183
|
+
test("the registered job — via a real configResolver + file-provider — deletes the binary", async () => {
|
|
184
|
+
// The dedicated cron-registration test (run-forget-cleanup.integration.test.ts)
|
|
185
|
+
// drives the job with `{ db, registry }` only — no configResolver, no file
|
|
186
|
+
// provider registered. `resolveProvider` throws internally, `resolveProvider`
|
|
187
|
+
// failure is caught and the hook does a row-only delete. So the cron proves
|
|
188
|
+
// status-flip + PII-anonymize but NEVER the actual Art.17 binary-erasure path.
|
|
189
|
+
const userId = uuid(4);
|
|
190
|
+
await seed.seedForgetUser(userId);
|
|
191
|
+
await seed.seedMembership(userId, TENANT);
|
|
192
|
+
const key = await seed.seedFile(uuid(401), TENANT, userId);
|
|
193
|
+
expect(await provider.exists(key)).toBe(true);
|
|
194
|
+
|
|
195
|
+
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
196
|
+
expect(job).toBeDefined();
|
|
197
|
+
if (!job) return;
|
|
198
|
+
|
|
199
|
+
const ctx: JobContext = {
|
|
200
|
+
db,
|
|
201
|
+
registry: stack.registry,
|
|
202
|
+
configResolver: configResolverForJobCtx,
|
|
203
|
+
systemUser: { id: SYSTEM_USER_ID, tenantId: TENANT, roles: ["all"] },
|
|
204
|
+
log: {
|
|
205
|
+
info() {},
|
|
206
|
+
warn() {},
|
|
207
|
+
error() {},
|
|
208
|
+
debug() {},
|
|
209
|
+
child(): JobContext["log"] {
|
|
210
|
+
return this;
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
triggeredBy: null,
|
|
214
|
+
...bridgeStub(),
|
|
215
|
+
};
|
|
216
|
+
await job.handler({}, ctx);
|
|
217
|
+
|
|
218
|
+
expect(await provider.exists(key)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
4
4
|
import { createConfigFeature } from "../../config/feature";
|
|
5
5
|
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
@@ -36,7 +36,7 @@ describe("user-data-rights read-only inspector screens", () => {
|
|
|
36
36
|
);
|
|
37
37
|
const list = f.screens["export-job-list"];
|
|
38
38
|
expect(list?.type).toBe("entityList");
|
|
39
|
-
expect(list?.access).toEqual({ roles:
|
|
39
|
+
expect(list?.access).toEqual({ roles: access.systemAdmin });
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test("export-job detail is strictly read-only (no create/delete, every field readOnly)", () => {
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
CHANGED
|
@@ -15,8 +15,17 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
15
15
|
import { randomBytes } from "node:crypto";
|
|
16
16
|
import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
17
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
createRegistry,
|
|
20
|
+
type Registry,
|
|
21
|
+
SYSTEM_TENANT_ID,
|
|
22
|
+
type TenantId,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
import {
|
|
25
|
+
archiveStream,
|
|
26
|
+
createEventsTable,
|
|
27
|
+
eventsTable,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
20
29
|
import {
|
|
21
30
|
createProjectionStateTable,
|
|
22
31
|
rebuildProjection,
|
|
@@ -232,4 +241,54 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
|
|
|
232
241
|
}>;
|
|
233
242
|
expect(survived[0]?.status).toBe(USER_STATUS.Restricted);
|
|
234
243
|
});
|
|
244
|
+
|
|
245
|
+
test("a corrupt row (stream archived out from under it) is reported in `failed` — the rest of the estate still backfills", async () => {
|
|
246
|
+
// Real-world corruption: a stale tenant-lifecycle cleanup or an operator
|
|
247
|
+
// mistake archives a user's event stream while the read_users row still
|
|
248
|
+
// physically exists. This must land in `failed`, not abort the whole run
|
|
249
|
+
// — everything after it in the estate scan would otherwise silently miss
|
|
250
|
+
// its user.updated backfill (DSGVO data-loss on an unrelated row).
|
|
251
|
+
const hash = await hashPassword(ALICE_PW);
|
|
252
|
+
const goodRow = await stack.http.writeOk<{ id: string }>(
|
|
253
|
+
UserHandlers.create,
|
|
254
|
+
{ email: "healthy.rebuild@example.com", passwordHash: hash, displayName: "Healthy" },
|
|
255
|
+
TestUsers.systemAdmin,
|
|
256
|
+
);
|
|
257
|
+
await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: goodRow.id });
|
|
258
|
+
|
|
259
|
+
const badRow = await stack.http.writeOk<{ id: string }>(
|
|
260
|
+
UserHandlers.create,
|
|
261
|
+
{ email: "corrupt.rebuild@example.com", passwordHash: hash, displayName: "Corrupt" },
|
|
262
|
+
TestUsers.systemAdmin,
|
|
263
|
+
);
|
|
264
|
+
await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: badRow.id });
|
|
265
|
+
// userEntity is systemStream:true (schema/user.ts) — its event stream
|
|
266
|
+
// lives on SYSTEM_TENANT_ID regardless of the creating tenant. Archiving
|
|
267
|
+
// under any other tenant would be a silent no-op against assertStreamWritable.
|
|
268
|
+
await archiveStream(stack.db, {
|
|
269
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
270
|
+
aggregateId: badRow.id,
|
|
271
|
+
aggregateType: "user",
|
|
272
|
+
archivedBy: "test-corruption",
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const { backfilled, failed } = await backfillUserLifecycleEvents(stack.db);
|
|
276
|
+
|
|
277
|
+
expect(failed).toHaveLength(1);
|
|
278
|
+
expect(failed[0]?.id).toBe(badRow.id);
|
|
279
|
+
expect(backfilled).toBeGreaterThanOrEqual(1);
|
|
280
|
+
|
|
281
|
+
// Proof the loop didn't abort at the corrupt row: rebuild the projection —
|
|
282
|
+
// goodRow got its user.updated event (survives), badRow never did (wiped
|
|
283
|
+
// back to Active, exactly the data-loss this backfill exists to prevent).
|
|
284
|
+
await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
|
|
285
|
+
const rows = (await selectMany(stack.db, userTable, {})) as Array<{
|
|
286
|
+
id: string;
|
|
287
|
+
status: string;
|
|
288
|
+
}>;
|
|
289
|
+
const good = rows.find((r) => r.id === goodRow.id);
|
|
290
|
+
const bad = rows.find((r) => r.id === badRow.id);
|
|
291
|
+
expect(good?.status).toBe(USER_STATUS.Restricted);
|
|
292
|
+
expect(bad?.status).toBe(USER_STATUS.Active);
|
|
293
|
+
});
|
|
235
294
|
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createTextField,
|
|
20
20
|
defineFeature,
|
|
21
21
|
EXT_USER_DATA,
|
|
22
|
+
type JobContext,
|
|
22
23
|
SYSTEM_USER_ID,
|
|
23
24
|
type UserDataDeleteHook,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
type TestStack,
|
|
30
31
|
unsafeCreateEntityTable,
|
|
31
32
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
33
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
32
34
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
33
35
|
import { createConfigFeature } from "../../config";
|
|
34
36
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -199,3 +201,43 @@ describe("forget pipeline honours the effective tenant model", () => {
|
|
|
199
201
|
expect(await rowCount()).toBe(1);
|
|
200
202
|
});
|
|
201
203
|
});
|
|
204
|
+
|
|
205
|
+
describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
|
|
206
|
+
test("the registered job resolves tenantModel via resolveAppTenantModel and actually erases", async () => {
|
|
207
|
+
// Every test above passes tenantModel as a literal string straight into
|
|
208
|
+
// runForgetCleanup — none exercise the job's OWN glue (feature.ts:
|
|
209
|
+
// resolveAppTenantModel(...) → runForgetCleanup({..., tenantModel})). A
|
|
210
|
+
// key-mismatch, forgotten argument, or wrong db-handle at either call site
|
|
211
|
+
// would pass every test above and still be broken in production.
|
|
212
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c4");
|
|
213
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
214
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT);
|
|
215
|
+
|
|
216
|
+
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
217
|
+
expect(job).toBeDefined();
|
|
218
|
+
if (!job) return;
|
|
219
|
+
|
|
220
|
+
const ctx: JobContext = {
|
|
221
|
+
db: stack.db,
|
|
222
|
+
registry: stack.registry,
|
|
223
|
+
configResolver: createConfigResolver({
|
|
224
|
+
appOverrides: new Map([[TENANT_MODEL_CONFIG_KEY, "single-user"]]),
|
|
225
|
+
}),
|
|
226
|
+
systemUser: { id: SYSTEM_USER_ID, tenantId: TENANT, roles: ["all"] },
|
|
227
|
+
log: {
|
|
228
|
+
info() {},
|
|
229
|
+
warn() {},
|
|
230
|
+
error() {},
|
|
231
|
+
debug() {},
|
|
232
|
+
child(): JobContext["log"] {
|
|
233
|
+
return this;
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
triggeredBy: null,
|
|
237
|
+
...bridgeStub(),
|
|
238
|
+
};
|
|
239
|
+
await job.handler({}, ctx);
|
|
240
|
+
|
|
241
|
+
expect(await rowCount()).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -314,7 +314,14 @@ async function processUser(args: {
|
|
|
314
314
|
|
|
315
315
|
hookCallsAttempted++;
|
|
316
316
|
await entry.deleteHook(
|
|
317
|
-
{
|
|
317
|
+
{
|
|
318
|
+
db: tx,
|
|
319
|
+
tenantId,
|
|
320
|
+
userId,
|
|
321
|
+
buildStorageProvider,
|
|
322
|
+
tenantModel,
|
|
323
|
+
userEmailBeforeDelete,
|
|
324
|
+
},
|
|
318
325
|
strategy,
|
|
319
326
|
);
|
|
320
327
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
type EntityEditScreenDefinition,
|
|
4
|
+
type EntityListScreenDefinition,
|
|
4
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
|
|
6
7
|
// Read-only operator inspector for the GDPR data-rights read-models. All
|
|
@@ -28,7 +29,7 @@ export const exportJobListScreen: EntityListScreenDefinition = {
|
|
|
28
29
|
},
|
|
29
30
|
],
|
|
30
31
|
searchable: false,
|
|
31
|
-
access: { roles:
|
|
32
|
+
access: { roles: access.systemAdmin },
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
export const exportJobDetailScreen: EntityEditScreenDefinition = {
|
|
@@ -58,7 +59,7 @@ export const exportJobDetailScreen: EntityEditScreenDefinition = {
|
|
|
58
59
|
// exists, and the export lifecycle is driven by the worker, not an operator.
|
|
59
60
|
allowCreate: false,
|
|
60
61
|
allowDelete: false,
|
|
61
|
-
access: { roles:
|
|
62
|
+
access: { roles: access.systemAdmin },
|
|
62
63
|
};
|
|
63
64
|
|
|
64
65
|
export const downloadAttemptListScreen: EntityListScreenDefinition = {
|
|
@@ -67,5 +68,5 @@ export const downloadAttemptListScreen: EntityListScreenDefinition = {
|
|
|
67
68
|
entity: "download-attempt",
|
|
68
69
|
columns: ["attemptedAt", "result", "via", "ip", "attemptedByUserId", "jobId"],
|
|
69
70
|
searchable: false,
|
|
70
|
-
access: { roles:
|
|
71
|
+
access: { roles: access.systemAdmin },
|
|
71
72
|
};
|
|
@@ -48,9 +48,11 @@ type QueryResponses = {
|
|
|
48
48
|
function makeDispatcher(
|
|
49
49
|
responses: QueryResponses,
|
|
50
50
|
writes: Array<{ type: string; payload: unknown }>,
|
|
51
|
+
queries: Array<{ type: string; payload: unknown }> = [],
|
|
51
52
|
): Dispatcher {
|
|
52
53
|
const statusStore = createStore<DispatcherStatus>("online");
|
|
53
|
-
const query = (async (type: string) => {
|
|
54
|
+
const query = (async (type: string, payload: unknown) => {
|
|
55
|
+
queries.push({ type, payload });
|
|
54
56
|
if (type === USER_ME_QUERY) return { isSuccess: true, data: responses.me };
|
|
55
57
|
if (type === UserDataRightsQueries.exportStatus) {
|
|
56
58
|
return { isSuccess: true, data: responses.exportStatus ?? { hasJob: false } };
|
|
@@ -74,11 +76,16 @@ function makeDispatcher(
|
|
|
74
76
|
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
function renderCenter(
|
|
79
|
+
function renderCenter(
|
|
80
|
+
responses: QueryResponses,
|
|
81
|
+
opts: { readonly showDeletion?: boolean } = {},
|
|
82
|
+
): {
|
|
78
83
|
view: ReturnType<typeof render>;
|
|
79
84
|
writes: Array<{ type: string; payload: unknown }>;
|
|
85
|
+
queries: Array<{ type: string; payload: unknown }>;
|
|
80
86
|
} {
|
|
81
87
|
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
88
|
+
const queries: Array<{ type: string; payload: unknown }> = [];
|
|
82
89
|
const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
83
90
|
<TokensProvider value={stubTokens}>
|
|
84
91
|
<LocaleProvider
|
|
@@ -87,7 +94,7 @@ function renderCenter(responses: QueryResponses): {
|
|
|
87
94
|
>
|
|
88
95
|
<PrimitivesProvider value={defaultPrimitives}>
|
|
89
96
|
<LiveEventsProvider value={stubLiveEvents}>
|
|
90
|
-
<DispatcherProvider dispatcher={makeDispatcher(responses, writes)}>
|
|
97
|
+
<DispatcherProvider dispatcher={makeDispatcher(responses, writes, queries)}>
|
|
91
98
|
{children}
|
|
92
99
|
</DispatcherProvider>
|
|
93
100
|
</LiveEventsProvider>
|
|
@@ -95,8 +102,10 @@ function renderCenter(responses: QueryResponses): {
|
|
|
95
102
|
</LocaleProvider>
|
|
96
103
|
</TokensProvider>
|
|
97
104
|
);
|
|
98
|
-
const view = render(<PrivacyCenterScreen
|
|
99
|
-
|
|
105
|
+
const view = render(<PrivacyCenterScreen showDeletion={opts.showDeletion ?? true} />, {
|
|
106
|
+
wrapper,
|
|
107
|
+
});
|
|
108
|
+
return { view, writes, queries };
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
const activeMe = {
|
|
@@ -197,6 +206,32 @@ describe("PrivacyCenterScreen", () => {
|
|
|
197
206
|
});
|
|
198
207
|
expect(writes[0]?.type).toBe(UserDataRightsHandlers.requestExport);
|
|
199
208
|
});
|
|
209
|
+
|
|
210
|
+
test("showDeletion=false: keine Lösch-Sektion", async () => {
|
|
211
|
+
const { view } = renderCenter({ me: activeMe }, { showDeletion: false });
|
|
212
|
+
await waitForMount(view);
|
|
213
|
+
expect(view.queryByTestId("privacy-deletion")).toBeNull();
|
|
214
|
+
expect(view.queryByTestId("privacy-deletion-delete")).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("Download-Button dispatcht downloadByJob mit der korrekten jobId", async () => {
|
|
218
|
+
const { view, queries } = renderCenter({
|
|
219
|
+
me: activeMe,
|
|
220
|
+
exportStatus: {
|
|
221
|
+
hasJob: true,
|
|
222
|
+
job: { id: "job-123", status: EXPORT_JOB_STATUS.Done, expiresAt: "2026-07-11T00:00:00Z" },
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
await waitForMount(view);
|
|
226
|
+
fireEvent.click(view.getByTestId("privacy-export-download"));
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
if (!queries.some((q) => q.type === UserDataRightsQueries.downloadByJob)) {
|
|
229
|
+
throw new Error("no downloadByJob query dispatched");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
const download = queries.find((q) => q.type === UserDataRightsQueries.downloadByJob);
|
|
233
|
+
expect(download?.payload).toEqual({ jobId: "job-123" });
|
|
234
|
+
});
|
|
200
235
|
});
|
|
201
236
|
|
|
202
237
|
describe("QN-Drift-Pins (client-Konstanten vs. Feature-Originale)", () => {
|