fix(quick-260910-krx): Konfliktklasse ueber den generierten Client messen statt sie zu behaupten

This commit is contained in:
2026-09-11 09:16:39 +02:00
parent b286bfb1a3
commit 6e7120648b
2 changed files with 101 additions and 1 deletions
+49 -1
View File
@@ -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 = '';
@@ -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: {} },