import { UnauthorizedException } from '@nestjs/common'; import { vi } from 'vitest'; import type { AppConfigService } from '../config/app-config.service.js'; import type { SapClientService } from '../sap/sap-client.service.js'; import { AuthService, escapeODataString } from './auth.service.js'; import type { AuditService } from '../audit/audit.service.js'; import { LoginAttemptsService } from './login-attempts.service.js'; import { SessionStoreService } from './session-store.service.js'; const employee = (over: Record = {}) => ({ EmployeeID: 7, FirstName: 'Ana', LastName: 'Pérez', Active: 'tYES', U_WEB_USER: 'ana@acme.com', U_WEB_PASS: 'secreto', ...over, }); describe('AuthService', () => { let rows: unknown[]; let get: ReturnType; let sessions: SessionStoreService; let service: AuthService; let record: ReturnType; beforeEach(() => { rows = [employee()]; get = vi.fn(async () => ({ data: { value: rows } })); sessions = new SessionStoreService({ get: () => 30 } as unknown as AppConfigService); record = vi.fn(async () => undefined); service = new AuthService({ get } as unknown as SapClientService, sessions, new LoginAttemptsService(), { record } as unknown as AuditService); }); it('login correcto devuelve token y usuario', async () => { const { token, user } = await service.login('ana@acme.com', 'secreto', '1.1.1.1'); expect(user).toEqual({ webUser: 'ana@acme.com', empId: 7, firstName: 'Ana', lastName: 'Pérez' }); expect(sessions.validate(token)?.empId).toBe(7); }); it('usuario inexistente y contraseña errónea dan el MISMO mensaje', async () => { const wrongPass = await service.login('ana@acme.com', 'mala', '1.1.1.1').catch((e) => e); rows = []; const unknown = await service.login('nadie', 'x', '1.1.1.1').catch((e) => e); expect(wrongPass).toBeInstanceOf(UnauthorizedException); expect(unknown).toBeInstanceOf(UnauthorizedException); expect(wrongPass.message).toBe(unknown.message); }); it('empleado inactivo con contraseña correcta -> "Usuario inactivo"', async () => { rows = [employee({ Active: 'tNO' })]; await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow('Usuario inactivo'); }); it('rechaza si el empleado no tiene contraseña web guardada', async () => { rows = [employee({ U_WEB_PASS: null })]; await expect(service.login('ana@acme.com', 'x', '1.1.1.1')).rejects.toThrow(UnauthorizedException); }); it('rechaza si hay dos empleados con el mismo U_WEB_USER', async () => { rows = [employee(), employee({ EmployeeID: 8 })]; await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow(UnauthorizedException); }); it('escapa las comillas del usuario en el $filter (sin inyección OData)', async () => { await service.login("x' or U_WEB_USER ne '", 'p', '1.1.1.1').catch(() => undefined); const query = get.mock.calls[0][1] as { $filter: string }; expect(query.$filter).toBe("U_WEB_USER eq 'x'' or U_WEB_USER ne '''"); expect(escapeODataString("o'neil")).toBe("o''neil"); }); it('bloquea tras 5 fallos y ya no consulta a SAP', async () => { for (let i = 0; i < 5; i++) await service.login('ana@acme.com', 'mala', '1.1.1.1').catch(() => undefined); get.mockClear(); await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow(/Demasiados intentos/); expect(get).not.toHaveBeenCalled(); }); describe('auditoría', () => { it('un login correcto queda registrado con usuario e ip', async () => { await service.login('ana@acme.com', 'secreto', '9.9.9.9'); expect(record).toHaveBeenCalledWith({ action: 'login', webUser: 'ana@acme.com', ip: '9.9.9.9' }); }); it('un login fallido queda registrado sin distinguir usuario inexistente de clave errónea, y sin la clave', async () => { await service.login('ana@acme.com', 'mala-clave', '9.9.9.9').catch(() => undefined); rows = []; await service.login('nadie', 'otra-clave', '9.9.9.9').catch(() => undefined); const calls = record.mock.calls.map((c) => c[0]); expect(calls).toEqual([ { action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'error', detail: { reason: 'credenciales' } }, { action: 'login_failed', webUser: 'nadie', ip: '9.9.9.9', outcome: 'error', detail: { reason: 'credenciales' } }, ]); expect(JSON.stringify(calls)).not.toContain('clave'); expect(JSON.stringify(calls)).not.toContain('secreto'); }); it('un usuario inactivo queda registrado como denegado', async () => { rows = [employee({ Active: 'tNO' })]; await service.login('ana@acme.com', 'secreto', '9.9.9.9').catch(() => undefined); expect(record).toHaveBeenCalledWith({ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'denied', detail: { reason: 'usuario inactivo' } }); }); it('el bloqueo por demasiados intentos queda registrado', async () => { for (let i = 0; i < 5; i++) await service.login('ana@acme.com', 'mala', '9.9.9.9').catch(() => undefined); record.mockClear(); await service.login('ana@acme.com', 'secreto', '9.9.9.9').catch(() => undefined); expect(record).toHaveBeenCalledWith({ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'denied', detail: { reason: 'demasiados intentos' } }); }); it('el cierre de sesión queda registrado', async () => { const { token, user } = await service.login('ana@acme.com', 'secreto', '9.9.9.9'); record.mockClear(); await service.logout(token, user, '9.9.9.9'); expect(sessions.validate(token)).toBeUndefined(); expect(record).toHaveBeenCalledWith({ action: 'logout', webUser: 'ana@acme.com', ip: '9.9.9.9' }); }); }); });