feat(quick-260910-krx): Suchmaschinen gebunden, Katalog begruendet offen, Klassifikation nachgezogen

Aufgabe 3 — TDD zuerst (7 weitere Faelle in dashboard.service.spec.ts, 20
vorher/27 nach dieser Aufgabe), dann die Umstellung:

- getSearchProviders/addSearchProvider/removeSearchProvider laufen ueber
  forTenant(); removeSearchProvider fuehrt Besitzpruefung UND Schreibzugriff
  ueber DENSELBEN gebundenen Klienten. Die drei Vorgabe-Suchmaschinen aus
  der Konstante bleiben unveraendert vorangestellt.
- Der eine Katalogzugriff (this.prisma.module in getWidgets) bleibt
  begruendet ungebunden: Messung und Bedingung getrennt (Tabelle traegt
  heute keinen Zeilenschutz, wirkungslos statt katastrophal — katastrophal
  erst, wenn Etappe 3 eine Regel gibt), unter Berufung auf die bestehende
  Werkzeugpruefung module-tabelle-traegt-keinen-zeilenschutz statt einer
  neuen Behauptung. Ein Wachhund-Testfall haelt den Katalogzugriff aus dem
  Bindungsprotokoll heraus (und beweist zuerst, dass der Katalogpfad
  tatsaechlich durchlaufen wird, nicht nur theoretisch geprueft ist).
- docs/mandantentrennung-zugriffsklassifikation.md an allen fuenf
  handgepflegten Stellen nachgezogen: vier Bestandsaufnahme-Zeilen (inkl.
  eigenstaendiger Nachpruefung der widerlegten SearchProvider-Praemisse),
  Uebersichtszeile (13/0 -> 1/12), Summenzeile (95/147), Klassen-Verteilung
  (unveraendert 63 Paare, ausdruecklich vermerkt), Hintergrunddienst-
  Abschnitt (dashboard hat keinen sechsten Fall, mit Messanweisung), "Was
  diese Etappe NICHT entscheidet" (dienst-interner forTenant()-Weg wie alle
  sieben Bereiche vor ihm).
- .planning/WINDOWS.md traegt Eintrag #25 (offen, Tabelle + JSON): die
  beweisvernichtende Schleife (leeres Dashboard -> Neuaufbau ->
  automatisches Zurueckschreiben -> ueberschriebene Anordnung, Widget-
  Dubletten) samt der Vorabpruefung fuer Etappe 4 und dem Verweis auf #22
  fuer die verwandte Eindeutigkeitsfrage.

Zwei weitere Falsifizierungsnachweise durchgefuehrt: (1) den Katalogzugriff
probeweise gebunden (tenantPrisma.module.findMany) — acht Tests werden rot
mit "TypeError: Cannot read properties of undefined (reading 'findMany')",
weil `module` bewusst nicht in der Testdouble-Bindungsliste steht; Rueckbau
zurueckgenommen, 27/27 wieder gruen. (2) den Stand von dashboardLayout in
der Klassifikationsdatei probeweise auf "ungebunden" gesetzt —
rls-access-inventory.spec.ts wird rot mit "Abweichender Stand (Dokument vs.
Quelltext): ... dokumentiert=ungebunden, gemessen=gebunden"; Ruecknahme,
Testlauf wieder gruen (10/10).

Baseline gehalten: 858 Tests / 56 Dateien gruen, Typpruefung sauber,
Wegwerf-Werkzeug 87/87. Schalter bleibt aus.
This commit is contained in:
2026-09-11 09:03:23 +02:00
parent e0ce594c5a
commit 67b50240d6
5 changed files with 260 additions and 25 deletions
@@ -40,8 +40,9 @@ 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'];
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
* ergaenzt seit Aufgabe 3 (260910-krx). */
const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider'];
function makeWidget(
overrides: Partial<{
@@ -63,16 +64,40 @@ function makeWidget(
};
}
function makeSearchProvider(
overrides: Partial<{
id: string;
userId: string | null;
tenantId: string | null;
name: string;
urlTemplate: string;
isDefault: boolean;
createdAt: Date;
}> = {},
) {
return {
id: overrides.id ?? 'sp1',
userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1',
name: overrides.name ?? 'Eigene Suche',
urlTemplate: overrides.urlTemplate ?? 'https://example.test/?q={query}',
isDefault: overrides.isDefault ?? false,
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
};
}
function makeFakePrisma(
opts: {
widgets?: ReturnType<typeof makeWidget>[];
modules?: { id: string; slug: string }[];
layout?: { userId: string; tenantId: string; layouts: unknown } | null;
searchProviders?: ReturnType<typeof makeSearchProvider>[];
} = {},
) {
const widgets = opts.widgets ?? [];
const modules = opts.modules ?? [];
let layoutRow = opts.layout ?? null;
const searchProviders = opts.searchProviders ?? [];
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const fake: any = {
@@ -124,6 +149,28 @@ function makeFakePrisma(
return modules.filter((m) => slugs.includes(m.slug));
}),
},
searchProvider: {
findMany: vi.fn(async ({ where }: any) => {
return searchProviders
.filter((p) => p.userId === where.userId)
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}),
create: vi.fn(async ({ data }: any) => {
const created = makeSearchProvider({ id: `new-sp-${searchProviders.length + 1}`, ...data });
searchProviders.push(created);
return created;
}),
findUnique: vi.fn(async ({ where }: any) => {
return searchProviders.find((p) => p.id === where.id) ?? null;
}),
delete: vi.fn(async ({ where }: any) => {
const idx = searchProviders.findIndex((p) => p.id === where.id);
if (idx === -1) return null;
const [removed] = searchProviders.splice(idx, 1);
return removed;
}),
},
// --- Bindungsnachweis (260910-krx, Muster aus 260910-exd) --------------
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
@@ -479,3 +526,124 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
expect(result).toEqual([]);
});
});
// --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) ---------
describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => {
beforeEach(() => {
for (const key of Object.keys(mockMap)) delete mockMap[key];
vi.mocked(forTenant).mockClear();
});
it('Suchmaschinen lesen: der Lesezugriff läuft gebunden, die drei Vorgabe-Suchmaschinen aus der Konstante werden UNVERÄNDERT vorangestellt', async () => {
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({ searchProviders: [own] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getSearchProviders('user-1', 'tenant-1');
expect(result.map((p: any) => p.id)).toEqual(['google', 'bing', 'ddg', 'sp1']);
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findMany');
});
it('Suchmaschine anlegen: gebunden, mit der übergebenen Mandantenkennung, die Mandantenkennung bleibt Pflichtangabe — wird rot, sobald ein Schreibweg ohne Mandantenkennung eingeführt wird', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const created = await service.addSearchProvider('user-1', 'tenant-1', {
name: 'Intranet',
urlTemplate: 'https://intranet.test/?q={query}',
} as any);
expect(created.tenantId).toBe('tenant-1');
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'create');
expect(prisma.searchProvider.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ tenantId: 'tenant-1' }) }),
);
});
it('Suchmaschine entfernen: BEIDE Abfragen über DENSELBEN gebundenen Klienten', async () => {
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({ searchProviders: [own] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.removeSearchProvider('sp1', 'user-1', 'tenant-1');
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'delete');
});
it('Besitzprüfung bleibt wirksam: die Suchmaschine eines anderen Benutzers führt weiterhin zu NotFoundException', async () => {
const foreign = makeSearchProvider({ id: 'sp1', userId: 'other-user' });
const prisma = makeFakePrisma({ searchProviders: [foreign] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(service.removeSearchProvider('sp1', 'user-1', 'tenant-1')).rejects.toThrow(
"Search provider with id 'sp1' not found",
);
});
it('eine der drei Vorgabe-Suchmaschinen lässt sich weiterhin nicht entfernen (userId null fällt in den Nicht-gefunden-Zweig)', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(service.removeSearchProvider('google', 'user-1', 'tenant-1')).rejects.toThrow(
"Search provider with id 'google' not found",
);
});
it('Wachhund: der Modulkatalog taucht im Bindungsprotokoll nie auf, auch nicht nach der Suchmaschinen-Bindung — und der Katalogpfad wird tatsächlich durchlaufen, nicht nur theoretisch geprüft', async () => {
mockMap['tender-radar'] = 'tender-radar';
const boundWidget = makeWidget({ id: 'w1', widgetType: 'tender-radar' });
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({
searchProviders: [own],
widgets: [boundWidget],
modules: [{ id: 'mod-1', slug: 'tender-radar' }],
});
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.getSearchProviders('user-1', 'tenant-1');
await service.addSearchProvider('user-1', 'tenant-1', {
name: 'Intranet',
urlTemplate: 'https://intranet.test/?q={query}',
} as any);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
// Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die
// Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren
// koennte).
expect(prisma.module.findMany).toHaveBeenCalled();
expectNeverBound(prisma, 'module');
});
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf — auch fuer die drei Suchmaschinen-Methoden', async () => {
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({ searchProviders: [own] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
for (const call of [
() => service.getSearchProviders('user-1', 'tenant-1'),
() =>
service.addSearchProvider('user-1', 'tenant-1', {
name: 'x',
urlTemplate: 'https://x.test/?q={query}',
} as any),
() => service.removeSearchProvider('sp1', '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);
}
});
});