From 6e7120648bc54ae9ba2e96180985304b9649adb5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 09:16:39 +0200 Subject: [PATCH] fix(quick-260910-krx): Konfliktklasse ueber den generierten Client messen statt sie zu behaupten --- apps/api/scripts/rls-scratch-check.mjs | 50 +++++++++++++++++- .../src/dashboard/dashboard.service.spec.ts | 52 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 7f9d90e..2711fcc 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -2451,7 +2451,9 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { id text PRIMARY KEY, "userId" text NOT NULL UNIQUE, "tenantId" text NOT NULL, - layouts jsonb NOT NULL DEFAULT '{}'::jsonb + layouts jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); await db.$executeRawUnsafe(` @@ -2604,6 +2606,52 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { conflictDetail, ); + // 5b: dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown + // + // Pruefung 5 misst mit Roh-SQL, WAS die Datenbank tut (42501). Der + // Anwendungscode in `saveLayout` faengt aber nicht SQLSTATEs, sondern die + // Fehlerklasse, die der GENERIERTE Prisma-Client daraus macht — und die + // ist nicht dieselbe wie beim P2002-Fall der Bereiche `tenders`/`user` + // (`PrismaClientKnownRequestError`), sondern + // `PrismaClientUnknownRequestError`, weil die Regel den Schreibzugriff + // abweist, bevor eine Eindeutigkeit ueberhaupt geprueft wird. Genau + // DIESE Klasse muss `saveLayout` uebersetzen; eine Uebersetzung der + // falschen Klasse spraenge nie an. + // + // Diese Messung fehlte in der ersten Lieferung von 260910-krx — die + // Zusammenfassung berief sich auf eine nicht committete Ad-hoc-Messung. + // Vom Verifizierer gefunden, hier nachgereicht: derselbe Vorgang wie in + // Pruefung 5, aber ueber `bound.dashboardLayout.upsert(...)` auf dem + // gebundenen generierten Client, und gepruegt wird der KONSTRUKTORNAME + // des geworfenen Fehlers. + let upsertThrewUnknown = false; + let upsertDetail = ''; + try { + const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); + await bound.dashboardLayout.upsert({ + where: { userId: 'user-conflict' }, + update: { layouts: {} }, + create: { + id: 'layout-conflict-attempt-upsert', + userId: 'user-conflict', + tenantId: 'TENANT-A', + layouts: {}, + }, + }); + upsertDetail = + 'gebundenes dashboardLayout.upsert unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (user-conflict) ist NICHT fehlgeschlagen'; + } catch (err) { + const ctor = err?.constructor?.name ?? 'unbekannt'; + upsertThrewUnknown = ctor === 'PrismaClientUnknownRequestError'; + upsertDetail = `gebundenes dashboardLayout.upsert unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''} — saveLayout uebersetzt genau diese Klasse; ${upsertThrewUnknown ? 'stimmt mit dem Anwendungscode ueberein' : 'STIMMT NICHT mit dem Anwendungscode ueberein, die Uebersetzung in saveLayout spraenge nie an'}`; + } + report( + results, + 'dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown', + upsertThrewUnknown, + upsertDetail, + ); + // 6: widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt let foreignWidgetInsertRejected = false; let foreignWidgetInsertDetail = ''; diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index 0a43ab9..333dece 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -35,6 +35,8 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); +import { ConflictException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { DashboardService } from './dashboard.service'; @@ -408,6 +410,56 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); }); + /** + * Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist + * plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile + * unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen + * Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler + * (`PrismaClientKnownRequestError`), sondern als + * `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den + * Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit + * ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs` + * (`dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown`). + * + * Dieser Test fehlte in der ersten Lieferung — die Zusammenfassung berief + * sich auf eine nicht committete Ad-hoc-Messung. Vom Verifizierer gefunden. + */ + describe('saveLayout: Konflikt auf unsichtbare Zeile (w4)', () => { + it('uebersetzt PrismaClientUnknownRequestError in eine ConflictException statt in einen 500', async () => { + const prisma = makeFakePrisma({}); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const unknown = new Prisma.PrismaClientUnknownRequestError( + 'Error occurred during query execution: ConnectorError(... new row violates row-level security policy ...)', + { clientVersion: 'test' }, + ); + prisma.dashboardLayout.upsert = vi.fn(async () => { + throw unknown; + }); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect( + service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('reicht einen bekannten Prisma-Fehler (z.B. P2002) unveraendert durch — der ist hier NICHT der gemessene Fall', async () => { + const prisma = makeFakePrisma({}); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const known = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: 'test', + }); + prisma.dashboardLayout.upsert = vi.fn(async () => { + throw known; + }); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect( + service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), + ).rejects.toBe(known); + }); + }); + it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => { const prisma = makeFakePrisma({ layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} },