@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.105.1",
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.1",
97
- "@cosmicdrift/kumiko-framework": "0.105.1",
98
- "@cosmicdrift/kumiko-headless": "0.105.1",
99
- "@cosmicdrift/kumiko-renderer": "0.105.1",
100
- "@cosmicdrift/kumiko-renderer-web": "0.105.1",
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 { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
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
- await user.click(screen.getByTestId("custom-fields-form-save"));
307
- await waitFor(() => expect(dispatchSpy).not.toHaveBeenCalled());
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 ["read_c7_widget", "read_c7_gadget", "read_c7_plain", "read_c7_stale"]) {
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: () => setDragOverKey((cur) => (cur === key ? null : cur)),
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
- const hasUnfiled = filing !== undefined && filing.unfiled.length > 0;
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: ["SystemAdmin"] });
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)", () => {
@@ -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 { createRegistry, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
19
- import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
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
- { db: tx, tenantId, userId, buildStorageProvider, tenantModel },
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 type {
2
- EntityEditScreenDefinition,
3
- EntityListScreenDefinition,
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: ["SystemAdmin"] },
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: ["SystemAdmin"] },
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: ["SystemAdmin"] },
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(responses: QueryResponses): {
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 />, { wrapper });
99
- return { view, writes };
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)", () => {