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
@@ -102,8 +102,8 @@ export class DashboardController {
@Get('search-providers')
async getSearchProviders(@Req() req: Request) {
const { userId } = this.extractContext(req);
return this.dashboardService.getSearchProviders(userId);
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getSearchProviders(userId, tenantId);
}
@Post('search-providers')
@@ -120,7 +120,7 @@ export class DashboardController {
@Param('id') id: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.dashboardService.removeSearchProvider(id, userId);
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeSearchProvider(id, userId, tenantId);
}
}
@@ -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);
}
});
});
+36 -8
View File
@@ -274,9 +274,13 @@ export class DashboardService {
/**
* Returns the three default providers merged with any user-custom providers.
* Defaults are always returned even with an empty DB (no seed migration needed).
* The three defaults come from the TypeScript constant above (decision
* 05-02), never from the database — they are unaffected by the binding
* below and are always prepended unchanged.
*/
async getSearchProviders(userId: string) {
const custom = await this.prisma.searchProvider.findMany({
async getSearchProviders(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const custom = await tenantPrisma.searchProvider.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
});
@@ -285,14 +289,19 @@ export class DashboardService {
}
/**
* Creates a user-custom search provider.
* Creates a user-custom search provider. `tenantId` stays a required
* parameter of this method — the only write path this model has (260910-krx,
* Aufgabe 1, Befund F, WINDOWS #19): no application path exists that
* creates a tenant-less row, which is why the RLS rule on `SearchProvider`
* was deliberately left unchanged/strict in migration 20260910120000.
*/
async addSearchProvider(
userId: string,
tenantId: string,
dto: CreateSearchProviderDto,
) {
return this.prisma.searchProvider.create({
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.searchProvider.create({
data: {
userId,
tenantId,
@@ -305,11 +314,14 @@ export class DashboardService {
/**
* Removes a user-custom search provider.
* Verifies ownership — default providers (userId null) cannot be deleted (T-05-07).
* Verifies ownership — default providers (userId null) cannot be deleted
* (T-05-07) — REAL, same reasoning as `updateWidgetConfig`/`removeWidget`
* above: both queries run over the SAME bound client and tenant id.
*/
async removeSearchProvider(id: string, userId: string) {
async removeSearchProvider(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
// Default providers have hardcoded IDs that won't exist in DB
const provider = await this.prisma.searchProvider.findUnique({
const provider = await tenantPrisma.searchProvider.findUnique({
where: { id },
});
@@ -319,8 +331,24 @@ export class DashboardService {
);
}
return this.prisma.searchProvider.delete({
return tenantPrisma.searchProvider.delete({
where: { id },
});
}
}
// --- Modulkatalog: bewusst ungebunden (260910-krx, Aufgabe 3) --------------
//
// Der eine verbleibende ungebundene Modellzugriff dieser Datei (das
// `module`-Modell in `getWidgets`, ueber den ungebundenen Basisclient)
// betrifft den plattformweiten Modulkatalog (`Module`).
// MESSUNG (rls-scratch-check.mjs, Pruefung `module-tabelle-traegt-keinen-
// zeilenschutz`, uebernommen aus dem Bereich `module-registry`, 260910-exd
// Befund E): die Tabelle traegt heute KEINEN Zeilenschutz — `pg_class.
// relrowsecurity` ist `false`, eine Bindung waere heute WIRKUNGSLOS, nicht
// katastrophal. BEDINGUNG: sie wuerde katastrophal, WENN Etappe 3 dieser
// Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer jeden
// Mandanten. Die Katalogaufloesung, die dieser Dienst fuer den Widget-
// Modulfilter aufruft (`ModuleAccessService.getAccessibleModuleIds`), bindet
// bereits seit 260910-exd in ihrem eigenen Dienst — dieser Zugriff wird hier
// NICHT ein zweites Mal gebunden.