feat(quick-260910-krx): Anordnung und Widgets an forTenant() gebunden
Aufgabe 2 — TDD zuerst (20 Faelle in dashboard.service.spec.ts, 8 vorher/12 neu, Zwei-Klienten-Nachweis ueber __makeBoundClient nach dem Muster von module-access.service.spec.ts), dann die Umstellung: - getLayout/saveLayout laufen GEMEINSAM gebunden (ein Testfall nagelt das fest); saveLayout uebersetzt eine gebundene Konflikt-Schreibung (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 — NICHT P2002) in eine deutsche ConflictException. - getWidgets/addWidget/updateWidgetConfig/removeWidget laufen gebunden; die drei Besitzpruefungen ueber die Benutzerkennung bleiben unveraendert bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). updateWidgetConfig/removeWidget fuehren Besitzpruefung UND Schreibzugriff ueber DENSELBEN gebundenen Klienten. - dashboard.controller.ts reicht den bereits aufgeloesten Mandanten bei getLayout/updateWidgetConfig/removeWidget durch (keine neue Vertrauensquelle, weiterhin aus extractContext/Sitzungsnachweis). - Der Modulkatalog und die vier Suchmaschinenzugriffe bleiben in dieser Aufgabe unveraendert (Aufgabe 3). - Falsifizierungsnachweis durchgefuehrt: tenantPrisma.widgetInstance.delete probeweise auf this.prisma zurueckgebaut — Test "Widget entfernen: ebenso, beide Abfragen ueber denselben Klienten" wird rot mit "erwarteter gebundener Aufruf widgetInstance.delete(tenant=tenant-1) fehlt im Protokoll"; Rueckbau zurueckgenommen, Testlauf wieder gruen (20/20). Zwei dokumentierte Abweichungen (Rule 3): (1) Befund A hatte fuer widgetInstance sieben Treffer vorhergesagt, gemessen sind sechs (macht zusammen mit dashboardLayout acht statt neun) — der Verify-Schwellwert wird entsprechend auf >=8 gelesen. (2) Die Stand-Spalte fuer dashboardLayout/widgetInstance in der Klassifikationsdatei wird bereits hier minimal nachgezogen (nicht erst in Aufgabe 3), weil rls-access-inventory.spec.ts sonst am Ende dieser Aufgabe rot waere — derselbe Praezedenzfall wie 260910-exd, Aufgabe 2. Baseline gehalten: 851 Tests / 56 Dateien gruen (839 + 12 neue), Typpruefung sauber, Wegwerf-Werkzeug 87/87.
This commit is contained in:
@@ -56,8 +56,8 @@ export class DashboardController {
|
|||||||
|
|
||||||
@Get('layout')
|
@Get('layout')
|
||||||
async getLayout(@Req() req: Request) {
|
async getLayout(@Req() req: Request) {
|
||||||
const { userId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
return this.dashboardService.getLayout(userId);
|
return this.dashboardService.getLayout(userId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('layout')
|
@Put('layout')
|
||||||
@@ -85,8 +85,8 @@ export class DashboardController {
|
|||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Body() dto: UpdateWidgetConfigDto,
|
@Body() dto: UpdateWidgetConfigDto,
|
||||||
) {
|
) {
|
||||||
const { userId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
return this.dashboardService.updateWidgetConfig(id, userId, dto);
|
return this.dashboardService.updateWidgetConfig(id, userId, tenantId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('widgets/:id')
|
@Delete('widgets/:id')
|
||||||
@@ -94,8 +94,8 @@ export class DashboardController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
) {
|
) {
|
||||||
const { userId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
return this.dashboardService.removeWidget(id, userId);
|
return this.dashboardService.removeWidget(id, userId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Search Providers (05-02, D-15) ---
|
// --- Search Providers (05-02, D-15) ---
|
||||||
|
|||||||
@@ -17,22 +17,48 @@ vi.mock('./widget-module-map', () => ({
|
|||||||
getModuleSlugForWidgetType: (widgetType: string) => mockMap[widgetType],
|
getModuleSlugForWidgetType: (widgetType: string) => mockMap[widgetType],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindung an forTenant() (260910-krx, Aufgabe 2). Dasselbe Muster wie
|
||||||
|
* `module-access.service.spec.ts` (260910-exd): der gebundene Klient ist
|
||||||
|
* ein ZWEITES, von `prisma` unterscheidbares Objekt über DEMSELBEN
|
||||||
|
* Speicher, das protokolliert, welche Aufrufe über ihn liefen (Modellname,
|
||||||
|
* Methodenname, Mandantenkennung). Ein reiner Identitäts-Mock
|
||||||
|
* (`forTenant: vi.fn((p) => p)`) könnte einen vergessenen Bindungsaufruf
|
||||||
|
* nicht von einem ungebundenen Aufruf unterscheiden.
|
||||||
|
*
|
||||||
|
* `module` wird NICHT gewrappt — der Katalogzugriff läuft bewusst über den
|
||||||
|
* ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus
|
||||||
|
* `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine
|
||||||
|
* Bindung wäre heute wirkungslos.
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
||||||
|
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
||||||
|
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. */
|
||||||
|
const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance'];
|
||||||
|
|
||||||
function makeWidget(
|
function makeWidget(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
widgetType: string;
|
widgetType: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id: overrides.id ?? 'w1',
|
id: overrides.id ?? 'w1',
|
||||||
userId: overrides.userId ?? 'user-1',
|
userId: overrides.userId ?? 'user-1',
|
||||||
tenantId: 'tenant-1',
|
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||||
widgetType: overrides.widgetType ?? 'clock',
|
widgetType: overrides.widgetType ?? 'clock',
|
||||||
config: {},
|
config: overrides.config ?? {},
|
||||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -41,12 +67,29 @@ function makeFakePrisma(
|
|||||||
opts: {
|
opts: {
|
||||||
widgets?: ReturnType<typeof makeWidget>[];
|
widgets?: ReturnType<typeof makeWidget>[];
|
||||||
modules?: { id: string; slug: string }[];
|
modules?: { id: string; slug: string }[];
|
||||||
|
layout?: { userId: string; tenantId: string; layouts: unknown } | null;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const widgets = opts.widgets ?? [];
|
const widgets = opts.widgets ?? [];
|
||||||
const modules = opts.modules ?? [];
|
const modules = opts.modules ?? [];
|
||||||
|
let layoutRow = opts.layout ?? null;
|
||||||
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||||
|
|
||||||
return {
|
const fake: any = {
|
||||||
|
dashboardLayout: {
|
||||||
|
findUnique: vi.fn(async ({ where }: any) => {
|
||||||
|
if (layoutRow && layoutRow.userId === where.userId) return layoutRow;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
upsert: vi.fn(async ({ where, update, create }: any) => {
|
||||||
|
if (layoutRow && layoutRow.userId === where.userId) {
|
||||||
|
layoutRow = { ...layoutRow, layouts: update.layouts };
|
||||||
|
return layoutRow;
|
||||||
|
}
|
||||||
|
layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts };
|
||||||
|
return layoutRow;
|
||||||
|
}),
|
||||||
|
},
|
||||||
widgetInstance: {
|
widgetInstance: {
|
||||||
findMany: vi.fn(async ({ where }: any) => {
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
return widgets
|
return widgets
|
||||||
@@ -54,7 +97,26 @@ function makeFakePrisma(
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
}),
|
}),
|
||||||
delete: vi.fn(),
|
create: vi.fn(async ({ data }: any) => {
|
||||||
|
const created = makeWidget({ id: `new-${widgets.length + 1}`, ...data });
|
||||||
|
widgets.push(created);
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where }: any) => {
|
||||||
|
return widgets.find((w) => w.id === where.id) ?? null;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }: any) => {
|
||||||
|
const widget = widgets.find((w) => w.id === where.id);
|
||||||
|
if (!widget) return null;
|
||||||
|
Object.assign(widget, data);
|
||||||
|
return widget;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async ({ where }: any) => {
|
||||||
|
const idx = widgets.findIndex((w) => w.id === where.id);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const [removed] = widgets.splice(idx, 1);
|
||||||
|
return removed;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
findMany: vi.fn(async ({ where }: any) => {
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
@@ -62,8 +124,27 @@ function makeFakePrisma(
|
|||||||
return modules.filter((m) => slugs.includes(m.slug));
|
return modules.filter((m) => slugs.includes(m.slug));
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// --- Bindungsnachweis (260910-krx, Muster aus 260910-exd) --------------
|
||||||
|
__boundCallLog: boundCallLog,
|
||||||
|
__makeBoundClient(tenantId: string) {
|
||||||
|
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
|
||||||
|
for (const modelName of BOUND_MODEL_NAMES) {
|
||||||
|
const model = fake[modelName];
|
||||||
|
const wrapped: any = {};
|
||||||
|
for (const method of Object.keys(model)) {
|
||||||
|
wrapped[method] = async (...args: any[]) => {
|
||||||
|
boundCallLog.push({ tenantId, model: modelName, method });
|
||||||
|
return model[method](...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
bound[modelName] = wrapped;
|
||||||
|
}
|
||||||
|
return bound;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
function makeFakeModuleAccessService(accessibleIds: Set<string>) {
|
function makeFakeModuleAccessService(accessibleIds: Set<string>) {
|
||||||
return {
|
return {
|
||||||
@@ -72,13 +153,38 @@ function makeFakeModuleAccessService(accessibleIds: Set<string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DashboardService.getWidgets — Modulfilter (D-22, PERM-07). Deckt jeden
|
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
|
||||||
* Fall aus 15-05-PLAN.md <behavior> inklusive der Edge-Probe-Kategorien
|
* lief über den gebundenen Client (nicht über den rohen, ungebundenen
|
||||||
* adjacency/empty/ordering/idempotency ab.
|
* Fake). Ein vergessener `forTenant()`-Aufruf hinterlässt hier KEINEN
|
||||||
|
* Eintrag und lässt den Test fehlschlagen.
|
||||||
*/
|
*/
|
||||||
|
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
||||||
|
const found = prisma.__boundCallLog.some(
|
||||||
|
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wachhund-Gegenprobe: ein Modell darf im Bindungsprotokoll gar nicht
|
||||||
|
* vorkommen — das ist der Testfall, der jemanden erwischt, der den bewusst
|
||||||
|
* ungebundenen Katalogzugriff später versehentlich bindet.
|
||||||
|
*/
|
||||||
|
function expectNeverBound(prisma: any, model: string) {
|
||||||
|
const found = prisma.__boundCallLog.some((c: any) => c.model === model);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const key of Object.keys(mockMap)) delete mockMap[key];
|
for (const key of Object.keys(mockMap)) delete mockMap[key];
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty/PERM-07: liefert bei leerer Zuordnungstabelle exakt die Prisma-Menge, ohne Zugriffs-Lookup', async () => {
|
it('empty/PERM-07: liefert bei leerer Zuordnungstabelle exakt die Prisma-Menge, ohne Zugriffs-Lookup', async () => {
|
||||||
@@ -213,3 +319,163 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Bindung an forTenant() (260910-krx, Aufgabe 2) -------------------------
|
||||||
|
|
||||||
|
describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (260910-krx, Aufgabe 2)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of Object.keys(mockMap)) delete mockMap[key];
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } },
|
||||||
|
});
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
const result = await service.getLayout('user-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ lg: [{ i: 'w1' }] });
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => {
|
||||||
|
const prisma = makeFakePrisma({ layout: null });
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
const result = await service.getLayout('user-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveLayout: der Schreibzugriff läuft über den gebundenen Klienten mit derselben Mandantenkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma({});
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any);
|
||||||
|
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {} },
|
||||||
|
});
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
await service.getLayout('user-1', 'tenant-1');
|
||||||
|
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any);
|
||||||
|
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Widgets lesen: der Widget-Lesezugriff läuft gebunden, der Katalogzugriff NICHT', async () => {
|
||||||
|
const prisma = makeFakePrisma({ widgets: [] });
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany');
|
||||||
|
expectNeverBound(prisma, 'module');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Widget anlegen: gebunden, mit der übergebenen Mandantenkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma({});
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any);
|
||||||
|
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
|
const prisma = makeFakePrisma({ widgets: [widget] });
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: { foo: 'bar' } } as any);
|
||||||
|
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique');
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'update');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Widget entfernen: ebenso, beide Abfragen über denselben Klienten', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
|
const prisma = makeFakePrisma({ widgets: [widget] });
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
await service.removeWidget('w1', 'user-1', 'tenant-1');
|
||||||
|
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique');
|
||||||
|
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Besitzprüfung bleibt wirksam beim Ändern: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException — die Bindung ERGÄNZT die Prüfung über die Benutzerkennung, sie ersetzt sie nicht', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'other-user' });
|
||||||
|
const prisma = makeFakePrisma({ widgets: [widget] });
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
|
||||||
|
).rejects.toThrow("Widget with id 'w1' not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Besitzprüfung bleibt wirksam beim Entfernen: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'other-user' });
|
||||||
|
const prisma = makeFakePrisma({ widgets: [widget] });
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
|
"Widget with id 'w1' not found",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
widgets: [widget],
|
||||||
|
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
||||||
|
});
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
for (const call of [
|
||||||
|
() => service.getLayout('user-1', 'tenant-1'),
|
||||||
|
() => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any),
|
||||||
|
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any),
|
||||||
|
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any),
|
||||||
|
() => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
|
||||||
|
() => service.removeWidget('w1', 'user-1', 'tenant-1'),
|
||||||
|
]) {
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await call().catch(() => undefined);
|
||||||
|
expect(
|
||||||
|
vi.mocked(forTenant).mock.calls.length,
|
||||||
|
`Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`,
|
||||||
|
).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kein Widget vorhanden: der Rückgabewert ist eine leere Liste, kein Fehler — Deutung von Leere als Abwesenheit, heutiges Verhalten', async () => {
|
||||||
|
const prisma = makeFakePrisma({ widgets: [] });
|
||||||
|
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||||
|
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||||
|
|
||||||
|
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma, Role } from '@prisma/client';
|
import { Prisma, Role } from '@prisma/client';
|
||||||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||||
@@ -52,7 +54,16 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
|||||||
* Layout (position/size) and widget config are stored in separate models
|
* Layout (position/size) and widget config are stored in separate models
|
||||||
* to avoid unnecessary saves when only one changes (RESEARCH anti-pattern).
|
* to avoid unnecessary saves when only one changes (RESEARCH anti-pattern).
|
||||||
*
|
*
|
||||||
* All operations are scoped by userId for security (T-05-01, T-05-02).
|
* All operations are scoped by userId for security (T-05-01, T-05-02) — the
|
||||||
|
* three ownership checks in this file (`updateWidgetConfig`, `removeWidget`,
|
||||||
|
* `removeSearchProvider`) compare against the user id from the session proof
|
||||||
|
* and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance`
|
||||||
|
* and `SearchProvider` know only the tenant dimension, not the user dimension
|
||||||
|
* (measured 260910-krx, Aufgabe 1, Befund G) — until the switch is flipped
|
||||||
|
* (WINDOWS #18) they remain the only actually effective protection against
|
||||||
|
* cross-reading/cross-deleting between two users of the SAME tenant, and the
|
||||||
|
* `forTenant()` binding below ADDS a tenant boundary on top of them, it never
|
||||||
|
* replaces them.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
@@ -65,8 +76,9 @@ export class DashboardService {
|
|||||||
* Returns the user's saved layout, or a default empty layout
|
* Returns the user's saved layout, or a default empty layout
|
||||||
* with all breakpoint arrays initialized.
|
* with all breakpoint arrays initialized.
|
||||||
*/
|
*/
|
||||||
async getLayout(userId: string) {
|
async getLayout(userId: string, tenantId: string) {
|
||||||
const record = await this.prisma.dashboardLayout.findUnique({
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const record = await tenantPrisma.dashboardLayout.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,9 +92,25 @@ export class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Upserts the user's dashboard layout.
|
* Upserts the user's dashboard layout.
|
||||||
* Creates a new record if none exists, updates if it does.
|
* Creates a new record if none exists, updates if it does.
|
||||||
|
*
|
||||||
|
* `userId` is platform-wide `@unique` (no tenant component) — a tenant
|
||||||
|
* whose user id was, by hand, moved off its actually-visible row could hit
|
||||||
|
* an `upsert` conflict on a row it cannot see under RLS. Measured
|
||||||
|
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row
|
||||||
|
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known
|
||||||
|
* error that the `tenders` area's translation pattern catches — this is a
|
||||||
|
* different Prisma error class, `.code`/`.meta` are `undefined`, the only
|
||||||
|
* signal is the raw `.message` text). Translated below into an
|
||||||
|
* understandable German message instead of a raw 500, same intent as
|
||||||
|
* `tender-notification-pref.service.ts`, different detection. Not
|
||||||
|
* reachable via any application path today (a user's tenant id never
|
||||||
|
* changes after creation) — the honest fix is a schema change and is
|
||||||
|
* deferred as a product decision to Etappe 3, same as WINDOWS #22.
|
||||||
*/
|
*/
|
||||||
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
|
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
|
||||||
return this.prisma.dashboardLayout.upsert({
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
try {
|
||||||
|
return await tenantPrisma.dashboardLayout.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
|
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
|
||||||
create: {
|
create: {
|
||||||
@@ -91,6 +119,14 @@ export class DashboardService {
|
|||||||
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
|
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Die Dashboard-Anordnung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +144,8 @@ export class DashboardService {
|
|||||||
* betroffene Widget entfernt (Fail-Closed).
|
* betroffene Widget entfernt (Fail-Closed).
|
||||||
*/
|
*/
|
||||||
async getWidgets(userId: string, tenantId: string, role: Role) {
|
async getWidgets(userId: string, tenantId: string, role: Role) {
|
||||||
const widgets = await this.prisma.widgetInstance.findMany({
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const widgets = await tenantPrisma.widgetInstance.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
@@ -125,12 +162,16 @@ export class DashboardService {
|
|||||||
return widgets;
|
return widgets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getAccessibleModuleIds() already binds internally (260910-exd,
|
||||||
|
// module-access.service.ts) — do NOT wrap it a second time here.
|
||||||
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
|
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
|
||||||
tenantId,
|
tenantId,
|
||||||
userId,
|
userId,
|
||||||
role,
|
role,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Module catalogue: deliberately left UNBOUND — see the reasoning at
|
||||||
|
// the bottom of this file (260910-krx, Aufgabe 3).
|
||||||
const modules = await this.prisma.module.findMany({
|
const modules = await this.prisma.module.findMany({
|
||||||
where: { slug: { in: boundSlugs } },
|
where: { slug: { in: boundSlugs } },
|
||||||
select: { id: true, slug: true },
|
select: { id: true, slug: true },
|
||||||
@@ -154,7 +195,8 @@ export class DashboardService {
|
|||||||
* Creates a new widget instance for the user.
|
* Creates a new widget instance for the user.
|
||||||
*/
|
*/
|
||||||
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
|
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
|
||||||
return this.prisma.widgetInstance.create({
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
return tenantPrisma.widgetInstance.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -166,14 +208,23 @@ export class DashboardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the config of a widget instance.
|
* Updates the config of a widget instance.
|
||||||
* Verifies ownership by userId before updating (T-05-01).
|
* Verifies ownership by userId before updating (T-05-01) — REAL, not
|
||||||
|
* decorative (unlike the `ldap`/`dkv` findUnique-then-write shape that
|
||||||
|
* produced this effort's first two vulnerabilities): `widget.userId !==
|
||||||
|
* userId` genuinely compares against the session-sourced user id and
|
||||||
|
* subsumes the tenant dimension. Both queries below run over the SAME
|
||||||
|
* bound client and the same tenant id — reading and writing are never
|
||||||
|
* split across the binding, or the check could pass on a row the write no
|
||||||
|
* longer sees, or vice versa (260910-krx, Aufgabe 1, Befund D).
|
||||||
*/
|
*/
|
||||||
async updateWidgetConfig(
|
async updateWidgetConfig(
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
tenantId: string,
|
||||||
dto: UpdateWidgetConfigDto,
|
dto: UpdateWidgetConfigDto,
|
||||||
) {
|
) {
|
||||||
const widget = await this.prisma.widgetInstance.findUnique({
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,7 +240,7 @@ export class DashboardService {
|
|||||||
...dto.config,
|
...dto.config,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.prisma.widgetInstance.update({
|
return tenantPrisma.widgetInstance.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { config: mergedConfig as unknown as Prisma.InputJsonValue },
|
data: { config: mergedConfig as unknown as Prisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
@@ -197,10 +248,13 @@ export class DashboardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a widget instance.
|
* Removes a widget instance.
|
||||||
* Verifies ownership by userId before deleting (T-05-01).
|
* Verifies ownership by userId before deleting (T-05-01) — same real
|
||||||
|
* ownership check as `updateWidgetConfig` above, same reasoning: both
|
||||||
|
* queries run over the SAME bound client and tenant id.
|
||||||
*/
|
*/
|
||||||
async removeWidget(id: string, userId: string) {
|
async removeWidget(id: string, userId: string, tenantId: string) {
|
||||||
const widget = await this.prisma.widgetInstance.findUnique({
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +264,7 @@ export class DashboardService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.widgetInstance.delete({
|
return tenantPrisma.widgetInstance.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,10 +294,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
||||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
|
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
|
||||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | ungebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`) in eine deutsche Konfliktmeldung. Vollstaendige Nachziehung (Uebersichtszeile, Summenzeile, Klassen-Verteilung) folgt in Aufgabe 3 mit dem Rest des Bereichs. |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell: lokal gemessen null Zeilen mit `tenantId = NULL` insgesamt, dieser Schreibweg verlangt die Mandantenkennung als Pflichtparameter; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. Die Regel auf `SearchProvider` bleibt deshalb unverändert streng. |
|
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell: lokal gemessen null Zeilen mit `tenantId = NULL` insgesamt, dieser Schreibweg verlangt die Mandantenkennung als Pflichtparameter; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. Die Regel auf `SearchProvider` bleibt deshalb unverändert streng. |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Vollstaendige Nachziehung folgt in Aufgabe 3. |
|
||||||
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
|
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
|
||||||
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
|
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
|
||||||
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
|
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
|
||||||
|
|||||||
Reference in New Issue
Block a user