import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Role } from '@prisma/client'; import { AuthService } from './auth.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; /** * Aufgabe 2 (260911-fh9): die Identitaets-Attrappe verschwindet. Der * Nachbau bekommt zwei UNTERSCHEIDBARE Klienten, in der Form von * `user.service.spec.ts`/`tenant.controller.spec.ts`: `forTenant()` wird * auf `__makeBoundClient(tenantId)` umgeleitet. Der UNGEBUNDENE Nachbau * (der ungebundene Basisclient selbst) hat `$queryRaw` und * `__makeBoundClient` — aber KEIN `user`- und KEIN `passwordResetToken`- * Modell: ein versehentlich ungebundener Modellzugriff scheitert mit * "Cannot read properties of undefined" (die dkv-Form der Falsifizierung). * Der GEBUNDENE Klient hat `user`/`passwordResetToken`, aber KEIN * `$queryRaw`: eine gebundene Anmeldesuche scheitert ebenso hart. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)), })); /** * Baut eine Tagged-Template-Attrappe fuer prisma.$queryRaw, die die * uebergebenen SQL-Textstuecke und interpolierten Werte aufzeichnet und ein * konfigurierbares Ergebnis liefert — ohne laufende Datenbank. */ function fakeQueryRaw(resultsByCall: unknown[][]) { let callIndex = 0; const calls: { strings: TemplateStringsArray; values: unknown[] }[] = []; const fn = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { calls.push({ strings, values }); const result = resultsByCall[callIndex] ?? []; callIndex += 1; return Promise.resolve(result); }); return { fn, calls }; } interface FakeUserRow { id: string; tenantId: string; username: string; email?: string | null; passwordHash: string | null; ldapDn: string | null; isActive: boolean; role: string; displayName: string | null; mustChangePassword: boolean; avatarPath?: string | null; accentColor?: string | null; dashboardBackground?: unknown; } interface BoundCall { tenantId: string; model: 'user' | 'passwordResetToken'; method: string; args: any; } /** * Zwei-Klienten-Nachbau (Muster `user.service.spec.ts`): `users` und * `resetTokens` sind das gemeinsame Gedaechtnis, der ungebundene Klient * (`$queryRaw`) und der gebundene Klient (`__makeBoundClient`) greifen auf * DIESELBEN Karten zu, protokollieren aber unterschiedlich — der * ungebundene protokolliert nicht, der gebundene schon. */ function makeFakePrisma(userRows: FakeUserRow[] = [], queryRawResults: unknown[][] = [[]]) { const users = new Map(userRows.map((u) => [u.id, { ...u }])); const resetTokens: any[] = []; const boundCallLog: BoundCall[] = []; const queryRaw = fakeQueryRaw(queryRawResults); function throwNotFound(): never { const err: any = new Error('Record to update not found'); err.code = 'P2025'; throw err; } function makeScopedUser(tenantId: string) { return { findUnique: async ({ where, select }: any) => { boundCallLog.push({ tenantId, model: 'user', method: 'findUnique', args: { where, select } }); const row = users.get(where.id); if (!row || row.tenantId !== tenantId) return null; if (!select) return { ...row }; const picked: any = {}; for (const key of Object.keys(select)) { if (select[key]) picked[key] = (row as any)[key]; } return picked; }, update: async ({ where, data }: any) => { boundCallLog.push({ tenantId, model: 'user', method: 'update', args: { where, data } }); const row = users.get(where.id); if (!row || row.tenantId !== tenantId) throwNotFound(); const updated = { ...row, ...data }; users.set(where.id, updated); return updated; }, }; } function makeScopedResetToken(tenantId: string) { return { create: async ({ data }: any) => { boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'create', args: { data } }); const record = { id: `rt-${resetTokens.length + 1}`, usedAt: null, ...data }; resetTokens.push(record); return record; }, update: async ({ where, data }: any) => { boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'update', args: { where, data } }); const idx = resetTokens.findIndex((t) => t.id === where.id); if (idx === -1) throwNotFound(); resetTokens[idx] = { ...resetTokens[idx], ...data }; return resetTokens[idx]; }, }; } const fake: any = { $queryRaw: queryRaw.fn, __users: users, __resetTokens: resetTokens, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { return { __isBoundClient: true, __tenantId: tenantId, user: makeScopedUser(tenantId), passwordResetToken: makeScopedResetToken(tenantId), }; }, }; return { prisma: fake, queryRaw }; } function expectBoundCall( prisma: any, tenantId: string, model: 'user' | 'passwordResetToken', method: string, ) { const found = prisma.__boundCallLog.some( (c: BoundCall) => 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); } /** * validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP * have no local passwordHash and must be authenticated by binding as their own * DN against the tenant's directory. These tests cover that branch; the local * password path (argon2) is unchanged and exercised elsewhere. * * Aufgabe 2 (260909-eor): validateUser sucht ab jetzt ueber * auth_lookup_user_by_username() via $queryRaw statt this.prisma.user.findUnique * — die Tests hier zeichnen $queryRaw statt user.findUnique auf. */ describe('AuthService.validateUser — LDAP login', () => { let service: AuthService; let prisma: any; let ldapService: any; let ldapConfigService: any; let queryRaw: ReturnType; const ldapUser = { id: 'u1', tenantId: 't1', username: 'alice', passwordHash: null, ldapDn: 'CN=alice,OU=Users,DC=ctl,DC=local', isActive: true, role: 'USER', displayName: null, mustChangePassword: false, }; beforeEach(() => { vi.clearAllMocks(); const built = makeFakePrisma([ldapUser as FakeUserRow], [[ldapUser]]); prisma = built.prisma; queryRaw = built.queryRaw; ldapService = { verifyUserCredentials: vi.fn() }; ldapConfigService = { getConfig: vi.fn().mockResolvedValue({ serverUrl: 'ldaps://balios.ctl.local:636', isActive: true, tlsRejectUnauthorized: false, }), }; service = new AuthService( prisma, {} as any, {} as any, {} as any, ldapService, ldapConfigService, ); }); it('authenticates an LDAP user via a successful directory bind', async () => { ldapService.verifyUserCredentials.mockResolvedValue(true); const result = await service.validateUser('Alice', 'ad-password'); expect(result).toEqual(ldapUser); expect(ldapService.verifyUserCredentials).toHaveBeenCalledWith( { serverUrl: 'ldaps://balios.ctl.local:636', tlsRejectUnauthorized: false, }, ldapUser.ldapDn, 'ad-password', ); expectBoundCall(prisma, 't1', 'user', 'update'); }); it('rejects an LDAP user when the directory bind fails', async () => { ldapService.verifyUserCredentials.mockResolvedValue(false); const result = await service.validateUser('alice', 'wrong'); expect(result).toBeNull(); expect(prisma.__boundCallLog).toHaveLength(0); }); it('rejects an LDAP user when no active LDAP config exists', async () => { ldapConfigService.getConfig.mockResolvedValue(null); const result = await service.validateUser('alice', 'pw'); expect(result).toBeNull(); expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled(); }); it('rejects a passwordless user that has no ldapDn (never binds)', async () => { const built = makeFakePrisma([{ ...ldapUser, ldapDn: null } as FakeUserRow], [[{ ...ldapUser, ldapDn: null }]]); prisma.$queryRaw = built.queryRaw.fn; const result = await service.validateUser('alice', 'pw'); expect(result).toBeNull(); expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled(); }); it('rejects an inactive LDAP user before any bind', async () => { queryRaw = fakeQueryRaw([[{ ...ldapUser, isActive: false }]]); prisma.$queryRaw = queryRaw.fn; const result = await service.validateUser('alice', 'pw'); expect(result).toBeNull(); expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled(); }); it('lowercases the username before calling auth_lookup_user_by_username', async () => { ldapService.verifyUserCredentials.mockResolvedValue(true); await service.validateUser('Alice', 'ad-password'); expect(queryRaw.calls).toHaveLength(1); expect(queryRaw.calls[0].values).toEqual(['alice']); }); it('returns null (never throws) when auth_lookup_user_by_username finds nothing', async () => { queryRaw = fakeQueryRaw([[]]); prisma.$queryRaw = queryRaw.fn; const result = await service.validateUser('unknown', 'pw'); expect(result).toBeNull(); }); it('scheitert an "Cannot read properties of undefined", wenn die Suche versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => { expect(prisma.user).toBeUndefined(); }); }); /** * Lokaler Kennwort-Anmeldeweg (argon2) sowie requestPasswordReset/ * resetPassword — decken die Aufgabe-2-Verhaltensfaelle ab: Anmeldesuche * findet den Benutzer weiterhin, Schreibzugriffe laufen nach gefundenem * Benutzer mandantengebunden. */ describe('AuthService.validateUser — lokales Kennwort', () => { let service: AuthService; let prisma: any; let queryRaw: ReturnType; let localUser: { id: string; tenantId: string; username: string; passwordHash: string; ldapDn: null; isActive: boolean; role: string; displayName: null; mustChangePassword: boolean; }; beforeEach(async () => { vi.clearAllMocks(); // Echter argon2-Hash statt Mock — argon2.verify laesst sich in ESM // nicht ueber vi.spyOn ersetzen (nicht konfigurierbarer Modul-Export). const argon2 = await import('argon2'); localUser = { id: 'u2', tenantId: 't1', username: 'bob', passwordHash: await argon2.hash('correct-password'), ldapDn: null, isActive: true, role: 'USER', displayName: null, mustChangePassword: false, }; const built = makeFakePrisma([localUser as FakeUserRow], [[localUser]]); prisma = built.prisma; queryRaw = built.queryRaw; service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); }); it('findet den Benutzer weiterhin und aktualisiert lastLoginAt mandantengebunden', async () => { const result = await service.validateUser('bob', 'correct-password'); expect(result).toEqual(localUser); expectBoundCall(prisma, 't1', 'user', 'update'); const updateCall = prisma.__boundCallLog.find( (c: BoundCall) => c.model === 'user' && c.method === 'update', ); expect(updateCall.args).toEqual({ where: { id: 'u2' }, data: { lastLoginAt: expect.any(Date) }, }); }); it('sucht die Anmeldedaten exakt EINMAL ungebunden ueber $queryRaw und schreibt lastLoginAt exakt EINMAL gebunden unter dem Mandanten der Funktionszeile — die Grenze zwischen Anmeldeweg und Nach-Anmeldung', async () => { await service.validateUser('bob', 'correct-password'); expect(queryRaw.calls).toHaveLength(1); expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1'); expectBoundCall(prisma, 't1', 'user', 'update'); }); }); describe('AuthService.requestPasswordReset', () => { let service: AuthService; let prisma: any; let mailService: any; let queryRaw: ReturnType; const emailUser = { id: 'u3', tenantId: 't1', email: 'bob@example.com', isActive: true, }; beforeEach(() => { vi.clearAllMocks(); const built = makeFakePrisma([], [[emailUser]]); prisma = built.prisma; queryRaw = built.queryRaw; mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) }; service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any); }); it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => { await service.requestPasswordReset('bob@example.com'); expectBoundCall(prisma, 't1', 'passwordResetToken', 'create'); const createCall = prisma.__boundCallLog.find( (c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'create', ); expect(createCall.args).toEqual({ data: { token: expect.any(String), userId: 'u3', expiresAt: expect.any(Date), }, }); // Drittes Argument (260914-eym): die tenantId des Empfaengers (emailUser // liegt unter 't1') — MailService baut daraus den Transport je Versand. expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith( 'bob@example.com', expect.any(String), 't1', ); }); it('kehrt bei unbekannter E-Mail wortlos zurueck (T-02-12, keine Enumeration)', async () => { queryRaw = fakeQueryRaw([[]]); prisma.$queryRaw = queryRaw.fn; await service.requestPasswordReset('unknown@example.com'); expect(prisma.__boundCallLog).toHaveLength(0); expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled(); }); }); describe('AuthService.resetPassword', () => { let service: AuthService; let prisma: any; let queryRaw: ReturnType; const resetTokenRow = { id: 'rt1', token: 'a-uuid-token', userId: 'u4', expiresAt: new Date(Date.now() + 60 * 60 * 1000), usedAt: null, tenantId: 't1', }; beforeEach(() => { vi.clearAllMocks(); const built = makeFakePrisma( [ { id: 'u4', tenantId: 't1', username: 'dave', passwordHash: 'old-hash', ldapDn: null, isActive: true, role: 'USER', displayName: null, mustChangePassword: true, }, ], [[resetTokenRow]], ); prisma = built.prisma; queryRaw = built.queryRaw; // Das Token selbst existiert im gebundenen Nachbau nicht automatisch — // fuer den Update-Zweig genuegt hier, dass das UPDATE ueber die // Kennung `rt1` gelingt; deshalb wird der Datensatz vorab ueber die // Anlage nachgebildet, bevor resetPassword() ihn aktualisiert. prisma.__resetTokens.push({ id: 'rt1', token: 'a-uuid-token', usedAt: null }); service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); }); it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => { await service.resetPassword('a-uuid-token', 'new-password'); expectBoundCall(prisma, 't1', 'user', 'update'); expectBoundCall(prisma, 't1', 'passwordResetToken', 'update'); const userUpdate = prisma.__boundCallLog.find( (c: BoundCall) => c.model === 'user' && c.method === 'update', ); expect(userUpdate.args).toEqual({ where: { id: 'u4' }, data: { passwordHash: expect.any(String), mustChangePassword: false }, }); const tokenUpdate = prisma.__boundCallLog.find( (c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'update', ); expect(tokenUpdate.args).toEqual({ where: { id: 'rt1' }, data: { usedAt: expect.any(Date) }, }); }); it('wirft bei unbekanntem Token', async () => { queryRaw = fakeQueryRaw([[]]); prisma.$queryRaw = queryRaw.fn; await expect(service.resetPassword('unknown', 'pw')).rejects.toThrow( 'Invalid or expired reset token', ); }); }); /** * getMe/changePassword/adminResetPassword — bisher OHNE einen einzigen * Testfall (Befund F). Alle drei binden seit Aufgabe 2 an den Mandanten * aus dem Sitzungsnachweis. */ describe('AuthService.getMe', () => { let service: AuthService; let prisma: any; const localUserRow: FakeUserRow = { id: 'u1', tenantId: 't1', username: 'alice', passwordHash: 'a-hash', ldapDn: null, isActive: true, role: 'USER', displayName: 'Alice', mustChangePassword: false, avatarPath: 'avatars/u1.png', accentColor: '#3b82f6', // quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen. dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' }, }; const ldapUserRow: FakeUserRow = { id: 'u2', tenantId: 't1', username: 'bob', passwordHash: null, ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local', isActive: true, role: 'USER', displayName: 'Bob', mustChangePassword: false, avatarPath: null, accentColor: null, dashboardBackground: null, }; beforeEach(() => { vi.clearAllMocks(); const built = makeFakePrisma([localUserRow, ldapUserRow]); prisma = built.prisma; service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); }); it('eigener Mandant, lokaler Benutzer: liefert die oeffentlichen Felder, isLocalUser true, hasAvatar true — passwordHash/ldapDn/avatarPath fehlen (T-gbh-03)', async () => { const result = await service.getMe('t1', 'u1'); expect(result).toMatchObject({ id: 'u1', username: 'alice', displayName: 'Alice', role: 'USER', tenantId: 't1', mustChangePassword: false, accentColor: '#3b82f6', dashboardBackground: { kind: 'preset', id: 'dunes' }, isLocalUser: true, hasAvatar: true, }); expect(result).not.toHaveProperty('passwordHash'); expect(result).not.toHaveProperty('ldapDn'); expect(result).not.toHaveProperty('avatarPath'); }); it('eigener Mandant, LDAP-Benutzer (kein Hash, ldapDn gesetzt): isLocalUser false', async () => { const result = await service.getMe('t1', 'u2'); expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false }); }); it('quick-260928-ujj: dashboardBackground NULL (nie gewaehlt) kommt als null zurueck', async () => { const result = await service.getMe('t1', 'u2'); expect(result).toHaveProperty('dashboardBackground', null); }); it('quick-260928-ujj: ungueltiger gespeicherter Hintergrund kommt als null zurueck (T-ujj-01)', async () => { prisma.__users.set('u1', { ...prisma.__users.get('u1'), dashboardBackground: { kind: 'image', imageId: '") ; background: url("x' }, }); const result = await service.getMe('t1', 'u1'); expect(result).toHaveProperty('dashboardBackground', null); }); it('quick-260928-ujj: dashboardBackground steht im select neben accentColor', async () => { await service.getMe('t1', 'u1'); const call = prisma.__boundCallLog.find((c: any) => c.method === 'findUnique'); expect(call.args.select).toMatchObject({ accentColor: true, dashboardBackground: true }); }); it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => { const result = await service.getMe('t2', 'u1'); expect(result).toBeNull(); }); it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { vi.mocked(forTenant).mockClear(); await service.getMe('t1', 'u1'); expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1'); }); }); describe('AuthService.changePassword', () => { let service: AuthService; let prisma: any; let jwtService: any; let configService: any; let response: any; let localUserRow: FakeUserRow; let ldapUserRow: FakeUserRow; beforeEach(async () => { vi.clearAllMocks(); const argon2 = await import('argon2'); localUserRow = { id: 'u1', tenantId: 't1', username: 'alice', passwordHash: await argon2.hash('current-password'), ldapDn: null, isActive: true, role: 'USER', displayName: 'Alice', mustChangePassword: false, }; ldapUserRow = { id: 'u2', tenantId: 't1', username: 'bob', passwordHash: null, ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local', isActive: true, role: 'USER', displayName: 'Bob', mustChangePassword: false, }; const built = makeFakePrisma([localUserRow, ldapUserRow]); prisma = built.prisma; jwtService = { sign: vi.fn().mockReturnValue('signed.jwt.token') }; configService = { get: vi.fn().mockReturnValue('development') }; response = { cookie: vi.fn() }; service = new AuthService(prisma, jwtService, configService, {} as any, {} as any, {} as any); }); it('eigener Mandant, richtiges aktuelles Kennwort: gebundenes update traegt neuen Hash und mustChangePassword=false, JWT signiert mit tenantId/mustChangePassword, Cookie gesetzt', async () => { const argon2 = await import('argon2'); await service.changePassword('t1', 'u1', 'current-password', 'brand-new-password', response); const updateCall = prisma.__boundCallLog.find( (c: BoundCall) => c.model === 'user' && c.method === 'update', ); expect(updateCall).toBeDefined(); expect(updateCall.args.where).toEqual({ id: 'u1' }); expect(updateCall.args.data.mustChangePassword).toBe(false); const isNewHashValid = await argon2.verify( updateCall.args.data.passwordHash, 'brand-new-password', ); expect(isNewHashValid).toBe(true); expect(jwtService.sign).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 't1', mustChangePassword: false }), ); expect(response.cookie).toHaveBeenCalledWith( 'session', 'signed.jwt.token', expect.objectContaining({ httpOnly: true }), ); }); it('FREMDER Mandant: UnauthorizedException, kein Schreibzugriff, kein Cookie', async () => { await expect( service.changePassword('t2', 'u1', 'current-password', 'new-password', response), ).rejects.toThrow('User not found or has no local password'); expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); expect(response.cookie).not.toHaveBeenCalled(); }); it('falsches aktuelles Kennwort: Current password is incorrect, kein Schreibzugriff', async () => { await expect( service.changePassword('t1', 'u1', 'wrong-password', 'new-password', response), ).rejects.toThrow('Current password is incorrect'); expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); }); it('LDAP-Benutzer (kein Hash): UnauthorizedException, kein Schreibzugriff', async () => { await expect( service.changePassword('t1', 'u2', 'anything', 'new-password', response), ).rejects.toThrow('User not found or has no local password'); expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); }); it('erzeugt genau EINEN gebundenen Klienten je Aufruf (Suche und Schreiben auf demselben)', async () => { vi.mocked(forTenant).mockClear(); await service.changePassword('t1', 'u1', 'current-password', 'new-password', response); expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); }); }); describe('AuthService.adminResetPassword', () => { let service: AuthService; let prisma: any; let targetUserRow: FakeUserRow; let superAdminTargetRow: FakeUserRow; beforeEach(() => { vi.clearAllMocks(); targetUserRow = { id: 'target', tenantId: 't1', username: 'carol', passwordHash: 'old-hash', ldapDn: null, isActive: true, role: 'USER', displayName: 'Carol', mustChangePassword: false, }; superAdminTargetRow = { id: 'boss', tenantId: 't1', username: 'dora', passwordHash: 'old-hash', ldapDn: null, isActive: true, role: 'SUPER_ADMIN', displayName: 'Dora', mustChangePassword: false, }; const built = makeFakePrisma([targetUserRow, superAdminTargetRow]); prisma = built.prisma; service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); }); it('eigener Mandant, Aufrufer ADMIN, Ziel USER: gebundenes update mit neuem Hash, mustChangePassword TRUE (Vorgabe, Parameter weggelassen)', async () => { const argon2 = await import('argon2'); await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password'); const updateCall = prisma.__boundCallLog.find( (c: BoundCall) => c.model === 'user' && c.method === 'update', ); expect(updateCall.args.where).toEqual({ id: 'target' }); expect(updateCall.args.data.mustChangePassword).toBe(true); const ok = await argon2.verify(updateCall.args.data.passwordHash, 'fresh-password'); expect(ok).toBe(true); }); it('mustChangePassword: false wird durchgereicht', async () => { await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password', false); const updateCall = prisma.__boundCallLog.find( (c: BoundCall) => c.model === 'user' && c.method === 'update', ); expect(updateCall.args.data.mustChangePassword).toBe(false); }); it('FREMDER Mandant: BadRequestException, Meldung woertlich "User not found", KEIN Schreibzugriff — nennt weder Halter noch Mandanten', async () => { await expect( service.adminResetPassword('t2', Role.ADMIN, 'target', 'fresh-password'), ).rejects.toThrow('User not found'); expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); }); it('Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: ForbiddenException (T-FH9-04), KEIN Schreibzugriff', async () => { await expect( service.adminResetPassword('t1', Role.ADMIN, 'boss', 'fresh-password'), ).rejects.toThrow(); // ForbiddenException expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); }); it('Aufrufer SUPER_ADMIN, Ziel SUPER_ADMIN: gelingt', async () => { await service.adminResetPassword('t1', Role.SUPER_ADMIN, 'boss', 'fresh-password'); expectBoundCall(prisma, 't1', 'user', 'update'); }); it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { vi.mocked(forTenant).mockClear(); await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password'); expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); }); });