feat(260911-e2s): Benutzerzaehler im TenantController binden (Fan-out je Mandant)

findAll/findOne/remove zaehlen Benutzer je Mandant jetzt ueber drei
gebundene Aufrufstellen (tenantPrisma.user.count mit where: { tenantId
}, in remove zusaetzlich isActive: true) statt ueber den
Relationszaehler, der nach dem Scharfschalten unbemerkt unter der
Regel von User gelaufen waere (260911-e2s, Aufgabe 1, Pruefungen 5-7).
Fan-out-Muster aus UserService.findAllForPlatformAdmin uebernommen; die
vier tenant-Zugriffe bleiben ungebunden (Tenant ohne Regel). Antwortform,
Meldungen und Statuscodes unveraendert.

tenant.controller.spec.ts legt die Testlage aus dem Nichts an (20
Faelle): Zwei-Klienten-Nachweis ueber __makeBoundClient, Rollen-
Metadaten-Test (Klasse SUPER_ADMIN, kein Handler ueberschreibt), Wachhund
gegen mehrfache Klientenerzeugung. Falsifizierungsnachweis durchgefuehrt:
der probeweise ungebundene Zaehler in findOne macht 2 Faelle rot mit
"Cannot read properties of undefined (reading 'count')" — die dkv-Form
der Falsifizierung, nicht nur eine falsche Zahl —, danach zurueckgenommen.

Klassifikation und Entwicklungsanleitung nachgezogen: 64 Paare (ein
neues, tenant.controller.ts/user), Uebersichtszeile 8/3, Klassen-
Verteilung 32 muss-mandantengebunden, Erkennungsluecke fuer
Relationseinbindungen im Kopf der Bestandsaufnahme benannt, "Zwei
belegte Befunde" und "Was diese Etappe NICHT entscheidet" (erster
Punkt aufgeloest). Beide Dokument-Falsifizierungsnachweise durchgefuehrt
(falsche Klasse macht rls-access-inventory.spec.ts rot, falsche
Uebersichtszahl macht das herleitende Gate rot), zurueckgenommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 10:58:27 +02:00
parent 17dca0dfad
commit c8de72e762
4 changed files with 486 additions and 54 deletions
@@ -0,0 +1,346 @@
import 'reflect-metadata';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { TenantController } from './tenant.controller';
/**
* TenantController.spec — legt die Testlage fuer diesen Bereich aus dem
* Nichts an (260911-e2s, Aufgabe 3, Befund I: vorher gab es nur
* `tenant.service.spec.ts` mit zwei Faellen zu `create`).
*
* Zwei-Klienten-Nachweis (Muster aus `../dkv/dkv.service.spec.ts`):
* `__makeBoundClient(tenantId)` bietet ein `user.count`-Modell, das
* zusaetzlich nach der Mandantenkennung filtert und jeden Aufruf in ein
* Bindungsprotokoll schreibt. Der UNGEBUNDENE Nachbau (`prisma.tenant.*`)
* hat absichtlich KEIN `user`-Modell — ein versehentlich ungebundener
* Zaehler scheitert dadurch mit "Cannot read properties of undefined"
* (die `dkv`-Form der Falsifizierung), nicht mit einer nur falschen Zahl.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
interface FakeTenantRow {
id: string;
name: string;
slug: string;
isActive: boolean;
createdAt: Date;
}
interface FakeUserRow {
id: string;
tenantId: string;
isActive: boolean;
}
function makeFakePrisma(tenantRows: FakeTenantRow[], userRows: FakeUserRow[]) {
const tenants = new Map(tenantRows.map((t) => [t.id, { ...t }]));
const users = [...userRows];
const boundCallLog: { tenantId: string; model: string; method: string; where: any }[] = [];
const tenantModel = {
findMany: vi.fn(async ({ orderBy }: { orderBy?: { name?: 'asc' | 'desc' } } = {}) => {
const rows = [...tenants.values()];
if (orderBy?.name === 'asc') rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}),
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => tenants.get(where.id) ?? null),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
const row = tenants.get(where.id) ?? null;
tenants.delete(where.id);
return row;
}),
};
const fake: any = {
tenant: tenantModel,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
return {
user: {
count: async ({
where,
}: {
where: { tenantId: string; isActive?: boolean };
}) => {
boundCallLog.push({ tenantId, model: 'user', method: 'count', where });
return users.filter(
(u) =>
u.tenantId === where.tenantId &&
(where.isActive === undefined || u.isActive === where.isActive),
).length;
},
},
};
},
};
return fake;
}
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);
}
function makeFakeTenantService() {
return {
create: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
} as any;
}
const TENANT_A: FakeTenantRow = {
id: 'TENANT-A',
name: 'A GmbH',
slug: 'tenant-a',
isActive: true,
createdAt: new Date('2026-01-01'),
};
const TENANT_B: FakeTenantRow = {
id: 'TENANT-B',
name: 'B GmbH',
slug: 'tenant-b',
isActive: true,
createdAt: new Date('2026-01-02'),
};
const TENANT_C: FakeTenantRow = {
id: 'TENANT-C',
name: 'C GmbH',
slug: 'tenant-c',
isActive: true,
createdAt: new Date('2026-01-03'),
};
describe('TenantController.findAll', () => {
it('drei Mandanten (A: zwei Benutzer, B: ein Benutzer, C: keiner): liefert drei Eintraege mit userCount 2/1/0, genau drei gebundene Zaehlaufrufe je Mandantenkennung', async () => {
const prisma = makeFakePrisma(
[TENANT_A, TENANT_B, TENANT_C],
[
{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true },
{ id: 'u-a2', tenantId: 'TENANT-A', isActive: true },
{ id: 'u-b1', tenantId: 'TENANT-B', isActive: true },
],
);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
const result = await controller.findAll();
expect(result).toEqual([
expect.objectContaining({ id: 'TENANT-A', userCount: 2 }),
expect.objectContaining({ id: 'TENANT-B', userCount: 1 }),
expect.objectContaining({ id: 'TENANT-C', userCount: 0 }),
]);
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
expect(prisma.__boundCallLog).toHaveLength(3);
expectBoundCall(prisma, 'TENANT-A', 'user', 'count');
expectBoundCall(prisma, 'TENANT-B', 'user', 'count');
expectBoundCall(prisma, 'TENANT-C', 'user', 'count');
for (const call of prisma.__boundCallLog) {
expect(call.where.tenantId).toBe(call.tenantId);
}
});
it('ohne Mandanten: leere Liste, KEIN gebundener Klient erzeugt', async () => {
const prisma = makeFakePrisma([], []);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
const result = await controller.findAll();
expect(result).toEqual([]);
expect(prisma.__boundCallLog).toHaveLength(0);
});
it('die Antwort traegt genau die Felder id, name, slug, isActive, createdAt, userCount', async () => {
const prisma = makeFakePrisma(
[TENANT_A],
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
const [entry] = await controller.findAll();
expect(Object.keys(entry).sort()).toEqual(
['createdAt', 'id', 'isActive', 'name', 'slug', 'userCount'].sort(),
);
});
});
describe('TenantController.findOne', () => {
it('unbekannte Kennung: NotFoundException, KEIN gebundener Klient', async () => {
const prisma = makeFakePrisma([TENANT_A], []);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
await expect(controller.findOne('unknown')).rejects.toThrow(
new NotFoundException('Tenant not found'),
);
expect(prisma.__boundCallLog).toHaveLength(0);
});
it('bekannte Kennung: userCount aus dem gebundenen Klienten UNTER DIESER Kennung', async () => {
const prisma = makeFakePrisma(
[TENANT_A, TENANT_B],
[
{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true },
{ id: 'u-b1', tenantId: 'TENANT-B', isActive: true },
{ id: 'u-b2', tenantId: 'TENANT-B', isActive: true },
],
);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
const result = await controller.findOne('TENANT-B');
expect(result).toEqual(expect.objectContaining({ id: 'TENANT-B', userCount: 2 }));
expectBoundCall(prisma, 'TENANT-B', 'user', 'count');
expect(prisma.__boundCallLog).toHaveLength(1);
expect(prisma.__boundCallLog[0].where.tenantId).toBe('TENANT-B');
});
});
describe('TenantController.remove', () => {
it('unbekannte Kennung: NotFoundException, kein gebundener Klient, tenant.delete nicht aufgerufen', async () => {
const prisma = makeFakePrisma([TENANT_A], []);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
await expect(controller.remove('unknown')).rejects.toThrow(
new NotFoundException('Tenant not found'),
);
expect(prisma.__boundCallLog).toHaveLength(0);
expect(prisma.tenant.delete).not.toHaveBeenCalled();
});
it('mit aktiven Benutzern: BadRequestException mit der heutigen Meldung, tenant.delete NICHT aufgerufen, der gebundene Zaehlaufruf traegt isActive=true', async () => {
const prisma = makeFakePrisma(
[TENANT_A],
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
await expect(controller.remove('TENANT-A')).rejects.toThrow(
new BadRequestException(
'Cannot delete tenant with active users. Deactivate or reassign users first.',
),
);
expect(prisma.tenant.delete).not.toHaveBeenCalled();
expectBoundCall(prisma, 'TENANT-A', 'user', 'count');
expect(prisma.__boundCallLog[0].where).toEqual({ tenantId: 'TENANT-A', isActive: true });
});
it('mit ausschliesslich inaktiven Benutzern: der Riegel laesst durch, tenant.delete wird ungebunden mit { where: { id } } aufgerufen, Antwort { message: "Tenant deleted" } (heutiges Verhalten — der Fremdschluessel, der das in der echten Datenbank abfaengt, existiert im Nachbau nicht)', async () => {
const prisma = makeFakePrisma(
[TENANT_A],
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: false }],
);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
const result = await controller.remove('TENANT-A');
expect(result).toEqual({ message: 'Tenant deleted' });
expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-A' } });
});
it('ohne Benutzer: geloescht, Antwort wie oben', async () => {
const prisma = makeFakePrisma([TENANT_C], []);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
const result = await controller.remove('TENANT-C');
expect(result).toEqual({ message: 'Tenant deleted' });
expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-C' } });
});
});
describe('TenantController.create / update — delegieren an die Dienst-Attrappe', () => {
it('create: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus, delegiert an den Dienst', async () => {
const prisma = makeFakePrisma([], []);
const tenantService = makeFakeTenantService();
tenantService.create.mockResolvedValue(TENANT_A);
const controller = new TenantController(tenantService, prisma as any);
const result = await controller.create({ name: 'A GmbH', slug: 'tenant-a' } as any);
expect(result).toBe(TENANT_A);
expect(tenantService.create).toHaveBeenCalledWith({ name: 'A GmbH', slug: 'tenant-a' });
expect(prisma.tenant.findMany).not.toHaveBeenCalled();
expect(prisma.tenant.findUnique).not.toHaveBeenCalled();
expect(prisma.__boundCallLog).toHaveLength(0);
});
it('update: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus (ausser der Existenzpruefung ueber den Dienst), delegiert an den Dienst', async () => {
const prisma = makeFakePrisma([], []);
const tenantService = makeFakeTenantService();
tenantService.findById.mockResolvedValue(TENANT_A);
tenantService.update.mockResolvedValue({ ...TENANT_A, name: 'Neuer Name' });
const controller = new TenantController(tenantService, prisma as any);
const result = await controller.update('TENANT-A', { name: 'Neuer Name' });
expect(result).toEqual(expect.objectContaining({ name: 'Neuer Name' }));
expect(tenantService.update).toHaveBeenCalledWith('TENANT-A', {
name: 'Neuer Name',
isActive: undefined,
});
expect(prisma.tenant.findMany).not.toHaveBeenCalled();
expect(prisma.__boundCallLog).toHaveLength(0);
});
});
describe('TenantController — Rollen-Metadaten (Befund E, T-E2S-01)', () => {
it('klassenweit ist genau [Role.SUPER_ADMIN] gesetzt', () => {
const roles = Reflect.getMetadata(ROLES_KEY, TenantController);
expect(roles).toEqual([Role.SUPER_ADMIN]);
});
it.each(['findAll', 'findOne', 'create', 'update', 'remove'] as const)(
'Handler %s traegt KEINE eigene Rollenmetadaten — eine schwaechere Handler-Rolle wuerde die Klassenrolle via getAllAndOverride ueberschreiben',
(handlerName) => {
const handlerRoles = Reflect.getMetadata(
ROLES_KEY,
(TenantController.prototype as any)[handlerName],
);
expect(handlerRoles).toBeUndefined();
},
);
});
describe('TenantController — Wachhund: hoechstens ein gebundener Klient je Aufruf und Mandant', () => {
it('findOne erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
const prisma = makeFakePrisma(
[TENANT_A],
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
await controller.findOne('TENANT-A');
expect(prisma.__boundCallLog).toHaveLength(1);
});
it('remove erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
const prisma = makeFakePrisma([TENANT_A], []);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
await controller.remove('TENANT-A');
expect(prisma.__boundCallLog).toHaveLength(1);
});
it('findAll erzeugt genau so viele gebundene Klienten wie Mandanten vorhanden sind', async () => {
const prisma = makeFakePrisma([TENANT_A, TENANT_B, TENANT_C], []);
const controller = new TenantController(makeFakeTenantService(), prisma as any);
await controller.findAll();
expect(prisma.__boundCallLog).toHaveLength(3);
});
});
+54 -27
View File
@@ -13,6 +13,7 @@ import {
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { TenantService } from './tenant.service';
@@ -21,6 +22,30 @@ import { TenantService } from './tenant.service';
* Tenant CRUD controller.
* D-10: Only Super-Admin can manage tenants.
* T-02-09: Tenant deletion blocked if active users exist.
*
* User counts (260911-e2s, Aufgabe 3): `findAll`/`findOne`/`remove` used to
* read the user count through a relation include on the four unbound
* tenant reads below. Prisma renders that as a single statement with a
* LEFT JOIN into the protected `User` table — which carries a row-level
* security rule. After the switch flips, an unbound relation count reads
* zero for every tenant (measured, 260911-e2s Aufgabe 1, Pruefungen 5-7),
* which would make the platform-admin tenant list show zero users
* everywhere and let the delete gate below pass through with active users
* still present. Fixed by the fan-out pattern `UserService
* .findAllForPlatformAdmin` already uses: read tenants unbound, then bind
* ONE user count per tenant via the tenant-binding helper. The explicit
* `where: { tenantId }` on each bound count is TODAY (role runs with
* BYPASSRLS, WINDOWS #18) the only filter actually in effect.
*
* The four tenant reads/writes below stay unbound on purpose — `Tenant`
* carries no row-level security rule in any shipped migration (measured,
* 260911-e2s Aufgabe 1, Pruefungen 1/2); nothing on `Tenant` itself needs
* binding.
*
* This controller keeps talking to Prisma directly rather than going
* through a service method (same pattern as `user.controller.ts`) — a
* deliberate choice, not an oversight; see
* docs/mandantentrennung-zugriffsklassifikation.md, "(n5)".
*/
@Controller('tenants')
@UseGuards(RolesGuard)
@@ -38,22 +63,26 @@ export class TenantController {
@Get()
async findAll() {
const tenants = await this.prisma.tenant.findMany({
include: {
_count: {
select: { users: true },
},
},
orderBy: { name: 'asc' },
});
return tenants.map((t: any) => ({
id: t.id,
name: t.name,
slug: t.slug,
isActive: t.isActive,
createdAt: t.createdAt,
userCount: t._count.users,
}));
const results: any[] = [];
for (const tenant of tenants) {
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
const userCount = await tenantPrisma.user.count({
where: { tenantId: tenant.id },
});
results.push({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
isActive: tenant.isActive,
createdAt: tenant.createdAt,
userCount,
});
}
return results;
}
/**
@@ -64,24 +93,24 @@ export class TenantController {
async findOne(@Param('id') id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
include: {
_count: {
select: { users: true },
},
},
});
if (!tenant) {
throw new NotFoundException('Tenant not found');
}
const tenantPrisma = forTenant(this.prisma, id) as any;
const userCount = await tenantPrisma.user.count({
where: { tenantId: id },
});
return {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
isActive: tenant.isActive,
createdAt: tenant.createdAt,
userCount: tenant._count.users,
userCount,
};
}
@@ -125,20 +154,18 @@ export class TenantController {
async remove(@Param('id') id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
include: {
_count: {
select: {
users: { where: { isActive: true } },
},
},
},
});
if (!tenant) {
throw new NotFoundException('Tenant not found');
}
if (tenant._count.users > 0) {
const tenantPrisma = forTenant(this.prisma, id) as any;
const activeUserCount = await tenantPrisma.user.count({
where: { tenantId: id, isActive: true },
});
if (activeUserCount > 0) {
throw new BadRequestException(
'Cannot delete tenant with active users. Deactivate or reassign users first.',
);