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:
@@ -17,22 +17,48 @@ vi.mock('./widget-module-map', () => ({
|
||||
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';
|
||||
|
||||
/** 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(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
widgetType: string;
|
||||
config: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? 'w1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: 'tenant-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
widgetType: overrides.widgetType ?? 'clock',
|
||||
config: {},
|
||||
config: overrides.config ?? {},
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
@@ -41,12 +67,29 @@ function makeFakePrisma(
|
||||
opts: {
|
||||
widgets?: ReturnType<typeof makeWidget>[];
|
||||
modules?: { id: string; slug: string }[];
|
||||
layout?: { userId: string; tenantId: string; layouts: unknown } | null;
|
||||
} = {},
|
||||
) {
|
||||
const widgets = opts.widgets ?? [];
|
||||
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: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
return widgets
|
||||
@@ -54,7 +97,26 @@ function makeFakePrisma(
|
||||
.slice()
|
||||
.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: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
@@ -62,7 +124,26 @@ function makeFakePrisma(
|
||||
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>) {
|
||||
@@ -72,13 +153,38 @@ function makeFakeModuleAccessService(accessibleIds: Set<string>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardService.getWidgets — Modulfilter (D-22, PERM-07). Deckt jeden
|
||||
* Fall aus 15-05-PLAN.md <behavior> inklusive der Edge-Probe-Kategorien
|
||||
* adjacency/empty/ordering/idempotency ab.
|
||||
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
|
||||
* lief über den gebundenen Client (nicht über den rohen, ungebundenen
|
||||
* 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)', () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
@@ -213,3 +319,163 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user