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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user