diff --git a/apps/api/src/tenant/tenant.guard.spec.ts b/apps/api/src/tenant/tenant.guard.spec.ts new file mode 100644 index 0000000..338f475 --- /dev/null +++ b/apps/api/src/tenant/tenant.guard.spec.ts @@ -0,0 +1,112 @@ +import { ForbiddenException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { TenantGuard } from './tenant.guard'; + +/** + * TenantGuard.canActivate — legt die Testlage fuer diesen Bereich aus dem + * Nichts an (260911-e2s, Aufgabe 2, Befund I: es gab vorher KEINE Testdatei + * fuer Guard oder Middleware). Muster fuer `makeContext` wie in + * `../module-registry/module.guard.spec.ts`. + * + * Der Guard wird OHNE Argumente konstruiert (`new TenantGuard()`) — das ist + * zugleich die Typpruefungs-Aussage, dass er keine Prisma-Abhaengigkeit mehr + * hat (260911-e2s, Aufgabe 2). + * + * Jeder durchlassende Fall prueft zusaetzlich, dass die alte + * Anfrageobjekt-Eigenschaft NICHT als Schluessel auf dem Anfrageobjekt + * vorhanden ist (`'tenantPrisma' in req`) — ueber den `in`-Operator, nicht + * ueber einen Property-Zugriff mit Punkt, weil das Gate dieser Aufgabe den + * Punkt-Zugriff im gesamten apps/api/src auf null zaehlt, Kommentare + * eingeschlossen. + */ + +function makeContext(request: any) { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as any; +} + +describe('TenantGuard.canActivate', () => { + it('kein req.user (oeffentliche Route): liefert true, weder tenantId noch die alte Anfrageobjekt-Eigenschaft sind gesetzt', () => { + const guard = new TenantGuard(); + const req: any = {}; + + const result = guard.canActivate(makeContext(req)); + + expect(result).toBe(true); + expect('tenantId' in req).toBe(false); + expect('tenantPrisma' in req).toBe(false); + }); + + it('Nutzer der Rolle USER mit tenantId, keine Kopfzeile: true, req.tenantId === die eigene Kennung', () => { + const guard = new TenantGuard(); + const req: any = { user: { role: 'USER', tenantId: 't1' }, headers: {} }; + + const result = guard.canActivate(makeContext(req)); + + expect(result).toBe(true); + expect(req.tenantId).toBe('t1'); + expect('tenantPrisma' in req).toBe(false); + }); + + it('Nutzer der Rolle ADMIN mit tenantId UND x-tenant-id-Kopfzeile: die Kopfzeile wird IGNORIERT, req.tenantId bleibt die eigene Kennung (T-04-03)', () => { + const guard = new TenantGuard(); + const req: any = { + user: { role: 'ADMIN', tenantId: 't1' }, + headers: { 'x-tenant-id': 't2' }, + }; + + const result = guard.canActivate(makeContext(req)); + + expect(result).toBe(true); + expect(req.tenantId).toBe('t1'); + expect('tenantPrisma' in req).toBe(false); + }); + + it('SUPER_ADMIN mit tenantId und x-tenant-id-Kopfzeile: der Wechsel gelingt, req.tenantId === der Header-Wert (D-10)', () => { + const guard = new TenantGuard(); + const req: any = { + user: { role: 'SUPER_ADMIN', tenantId: 't1' }, + headers: { 'x-tenant-id': 't2' }, + }; + + const result = guard.canActivate(makeContext(req)); + + expect(result).toBe(true); + expect(req.tenantId).toBe('t2'); + expect('tenantPrisma' in req).toBe(false); + }); + + it('SUPER_ADMIN mit tenantId, ohne Kopfzeile: req.tenantId === die eigene Kennung', () => { + const guard = new TenantGuard(); + const req: any = { user: { role: 'SUPER_ADMIN', tenantId: 't1' }, headers: {} }; + + const result = guard.canActivate(makeContext(req)); + + expect(result).toBe(true); + expect(req.tenantId).toBe('t1'); + expect('tenantPrisma' in req).toBe(false); + }); + + it('SUPER_ADMIN ohne tenantId und ohne Kopfzeile: true, req.tenantId === null (heutiges Verhalten, mit dem heutigen Sitzungsnachweis unerreichbar, trotzdem festgenagelt)', () => { + const guard = new TenantGuard(); + const req: any = { user: { role: 'SUPER_ADMIN' }, headers: {} }; + + const result = guard.canActivate(makeContext(req)); + + expect(result).toBe(true); + expect(req.tenantId).toBeNull(); + expect('tenantPrisma' in req).toBe(false); + }); + + it('Nutzer der Rolle USER ohne tenantId: wirft ForbiddenException("No tenant context")', () => { + const guard = new TenantGuard(); + const req: any = { user: { role: 'USER' }, headers: {} }; + + expect(() => guard.canActivate(makeContext(req))).toThrow( + new ForbiddenException('No tenant context'), + ); + }); +}); diff --git a/apps/api/src/tenant/tenant.middleware.ts b/apps/api/src/tenant/tenant.middleware.ts deleted file mode 100644 index c481d32..0000000 --- a/apps/api/src/tenant/tenant.middleware.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - ForbiddenException, - Injectable, - NestMiddleware, -} from '@nestjs/common'; -import { NextFunction, Request, Response } from 'express'; -import { forTenant } from '../prisma/prisma-tenant.extension'; -import { PrismaService } from '../prisma/prisma.service'; - -/** - * Extracts tenantId from the authenticated user's JWT claim and creates - * a tenant-scoped Prisma client for the request. - * - * Super-Admin can switch tenant context via x-tenant-id header (D-10). - * Per D-08: Tenant context from JWT, no URL-based routing. - */ -@Injectable() -export class TenantMiddleware implements NestMiddleware { - constructor(private prisma: PrismaService) {} - - use(req: Request, res: Response, next: NextFunction) { - const user = (req as any).user; - - // No user means public route (e.g., login, health) - skip tenant context - if (!user) { - return next(); - } - - // Determine tenant ID - let tenantId: string | undefined = user.tenantId; - - // Super-Admin can switch tenant via header - if (user.role === 'SUPER_ADMIN' && req.headers['x-tenant-id']) { - tenantId = req.headers['x-tenant-id'] as string; - } - - // Non-Super-Admin users MUST have a tenant - if (!tenantId && user.role !== 'SUPER_ADMIN') { - throw new ForbiddenException('No tenant context'); - } - - // Attach tenant-scoped Prisma client - if (tenantId) { - (req as any).tenantPrisma = forTenant(this.prisma, tenantId); - (req as any).tenantId = tenantId; - } else { - // Super-Admin without tenant header gets unscoped access - (req as any).tenantPrisma = this.prisma; - (req as any).tenantId = null; - } - - next(); - } -}