feat(api): login web con sesiones, guard global y freno a fuerza bruta

- POST /api/auth/login (U_WEB_USER/U_WEB_PASS de EmployeesInfo), logout y me.
- Token opaco (32 bytes) en Authorization: Bearer; una sesión por usuario,
  timeout deslizante configurable (SESSION_TIMEOUT_MINUTES, 30 por defecto).
- AuthGuard global con @Public(); /health/sap ahora exige sesión.
- Usuario inexistente y contraseña errónea devuelven el mismo mensaje;
  comparación en tiempo constante; filtro OData escapado; se rechazan
  U_WEB_USER duplicados.
- LoginAttemptsService: 429 tras 5 fallos por usuario o 20 por IP (15 min).
- Tests unitarios y e2e con SAP simulado.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 18:38:11 -05:00
1 parent bb4acb5510
commit 079cedbaed
18 files changed
+673 -6

No files matched your search

+4 -2
View File
@@ -4,6 +4,8 @@ PORT=3000
CORS_ORIGINS=http://localhost:4200
# error | warn | log | debug | verbose
LOG_LEVEL=log
# Inactividad (minutos) tras la cual caduca la sesión web
SESSION_TIMEOUT_MINUTES=30
# --- SAP Business One Service Layer ---
SAPB1_SL_URL=https://servidor-sap:50000/b1s/v1
@@ -12,8 +14,8 @@ SAPB1_SL_COMPANYDB=
SAPB1_SL_USER=
SAPB1_SL_PASSWORD=
SAPB1_SL_TIMEOUT_MS=15000
# Peticiones simultáneas hacia el Service Layer (el resto se encola). Con más de
# ~2 en paralelo el Service Layer de prueba retrasaba ~7 s cada petición extra.
# Peticiones simultáneas hacia el Service Layer (el resto se encola). En el servidor
# de prueba, 1 era lo óptimo (~20 req/s); con 2 o más se degradaba. Medir en cada cliente.
SAPB1_SL_MAX_CONCURRENCY=1
# Confianza TLS (elegir según el caso):
+19
View File
@@ -13,3 +13,22 @@ npm run lint
Configuración: `src/config/env.schema.ts` valida el `.env` al arrancar (zod); si algo es inválido, el proceso no inicia.
Nota: instalar dependencias con npm 11 (`npx npm@11 install`); npm 10 falla al resolver peers con TypeScript 6.
## Autenticación
Login web contra `EmployeesInfo` (`U_WEB_USER` / `U_WEB_PASS`); el backend usa la sesión SAP compartida.
Guard global: todo `/api/*` exige `Authorization: Bearer <token>` salvo rutas `@Public()`.
| Endpoint | Descripción |
|---|---|
| `POST /api/auth/login` `{username, password}` | Devuelve `{token, user, company}` |
| `GET /api/auth/me` | Usuario de la sesión |
| `POST /api/auth/logout` | Cierra la sesión (204) |
- Una sesión por usuario (un nuevo login invalida el token anterior); timeout deslizante `SESSION_TIMEOUT_MINUTES` (30).
- Tras 5 fallos por usuario (o 20 por IP) en 15 min responde 429.
- Sesiones en memoria: ejecutar un solo proceso.
```bash
curl -s -H 'Content-Type: application/json' -d '{"username":"USUARIO","password":"CLAVE"}' localhost:3000/api/auth/login
```
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module.js';
import { AppConfigModule } from './config/config.module.js';
import { HealthController } from './health/health.controller.js';
import { SapModule } from './sap/sap.module.js';
@Module({
imports: [AppConfigModule, SapModule],
imports: [AppConfigModule, SapModule, AuthModule],
controllers: [HealthController],
})
export class AppModule {}
+40
View File
@@ -0,0 +1,40 @@
import { Body, Controller, Get, HttpCode, Post, Req } from '@nestjs/common';
import { z } from 'zod';
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
import { AppConfigService } from '../config/app-config.service.js';
import type { AuthenticatedRequest } from './auth.types.js';
import { AuthService } from './auth.service.js';
import { Public } from './public.decorator.js';
const loginSchema = z.object({
username: z.string().trim().min(1).max(100),
password: z.string().min(1).max(200),
});
type LoginDto = z.infer<typeof loginSchema>;
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly config: AppConfigService,
) {}
@Public()
@Post('login')
@HttpCode(200)
async login(@Body(new ZodValidationPipe(loginSchema)) body: LoginDto, @Req() req: AuthenticatedRequest) {
const { token, user } = await this.auth.login(body.username, body.password, req.ip ?? 'desconocida');
return { token, user, company: this.config.get('SAPB1_SL_COMPANYDB') };
}
@Post('logout')
@HttpCode(204)
logout(@Req() req: AuthenticatedRequest): void {
this.auth.logout(req.sessionToken);
}
@Get('me')
me(@Req() req: AuthenticatedRequest) {
return { user: req.user, company: this.config.get('SAPB1_SL_COMPANYDB') };
}
}
+38
View File
@@ -0,0 +1,38 @@
import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedRequest } from './auth.types.js';
import { IS_PUBLIC_KEY } from './public.decorator.js';
import { SessionStoreService } from './session-store.service.js';
/** Guard global: exige `Authorization: Bearer <token>` salvo en rutas @Public(). */
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionStoreService,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = bearerToken(req.headers.authorization);
if (!token) throw new UnauthorizedException('Se requiere autenticación. Inicie sesión primero.');
const user = this.sessions.validate(token);
if (!user) throw new UnauthorizedException('Sesión expirada. Debe iniciar sesión nuevamente.');
req.user = user;
req.sessionToken = token;
return true;
}
}
function bearerToken(header: string | undefined): string | undefined {
const [scheme, token] = header?.split(' ') ?? [];
return scheme?.toLowerCase() === 'bearer' && token ? token : undefined;
}
+21
View File
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { SapModule } from '../sap/sap.module.js';
import { AuthController } from './auth.controller.js';
import { AuthGuard } from './auth.guard.js';
import { AuthService } from './auth.service.js';
import { LoginAttemptsService } from './login-attempts.service.js';
import { SessionStoreService } from './session-store.service.js';
@Module({
imports: [SapModule],
controllers: [AuthController],
providers: [
AuthService,
SessionStoreService,
LoginAttemptsService,
{ provide: APP_GUARD, useClass: AuthGuard },
],
exports: [SessionStoreService],
})
export class AuthModule {}
+75
View File
@@ -0,0 +1,75 @@
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 { LoginAttemptsService } from './login-attempts.service.js';
import { SessionStoreService } from './session-store.service.js';
const employee = (over: Record<string, unknown> = {}) => ({
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<typeof vi.fn>;
let sessions: SessionStoreService;
let service: AuthService;
beforeEach(() => {
rows = [employee()];
get = vi.fn(async () => ({ data: { value: rows } }));
sessions = new SessionStoreService({ get: () => 30 } as unknown as AppConfigService);
service = new AuthService({ get } as unknown as SapClientService, sessions, new LoginAttemptsService());
});
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();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { SapClientService } from '../sap/sap-client.service.js';
import type { AuthUser } from './auth.types.js';
import { LoginAttemptsService } from './login-attempts.service.js';
import { SessionStoreService } from './session-store.service.js';
interface EmployeeRow {
EmployeeID: number;
FirstName: string | null;
LastName: string | null;
Active: string;
U_WEB_USER: string | null;
U_WEB_PASS: string | null;
}
const INVALID_CREDENTIALS = 'Usuario o contraseña incorrectos';
/** Escapa una cadena para usarla entre comillas simples en un $filter OData. */
export const escapeODataString = (s: string) => s.replace(/'/g, "''");
/** Comparación en tiempo constante (se comparan hashes para igualar longitudes). */
function safeEqual(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest();
const hb = createHash('sha256').update(b).digest();
return timingSafeEqual(ha, hb);
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly sap: SapClientService,
private readonly sessions: SessionStoreService,
private readonly attempts: LoginAttemptsService,
) {}
async login(username: string, password: string, ip: string): Promise<{ token: string; user: AuthUser }> {
this.attempts.assertAllowed(username, ip);
const employee = await this.findEmployee(username);
// Se compara siempre (aunque no exista el usuario) para no filtrar su existencia por tiempo.
const stored = employee?.U_WEB_PASS ?? '';
const passwordOk = safeEqual(stored, password) && stored !== '';
if (!employee || !passwordOk) {
this.attempts.recordFailure(username, ip);
this.logger.warn(`Login fallido para "${username}"`);
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (employee.Active !== 'tYES') {
this.logger.warn(`Login rechazado: empleado ${employee.EmployeeID} inactivo`);
throw new UnauthorizedException('Usuario inactivo');
}
this.attempts.recordSuccess(username);
const user: AuthUser = {
webUser: employee.U_WEB_USER ?? username,
empId: employee.EmployeeID,
firstName: employee.FirstName ?? '',
lastName: employee.LastName ?? '',
};
const token = this.sessions.create(user);
this.logger.log(`Login exitoso: ${user.webUser} (empleado ${user.empId})`);
return { token, user };
}
logout(token: string): void {
this.sessions.remove(token);
}
private async findEmployee(username: string): Promise<EmployeeRow | undefined> {
const { data } = await this.sap.get<{ value: EmployeeRow[] }>('EmployeesInfo', {
$select: 'EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS',
$filter: `U_WEB_USER eq '${escapeODataString(username)}'`,
});
if (data.value.length > 1) {
// Dos empleados con el mismo U_WEB_USER: ambiguo, no se autentica a ninguno.
this.logger.error(`U_WEB_USER duplicado en EmployeesInfo: "${username}"`);
return undefined;
}
return data.value[0];
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { Request } from 'express';
/** Empleado SAP autenticado en la web (campos estándar de EmployeesInfo). */
export interface AuthUser {
webUser: string;
empId: number;
firstName: string;
lastName: string;
}
export interface AuthenticatedRequest extends Request {
user: AuthUser;
sessionToken: string;
}
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, vi } from 'vitest';
import { LoginAttemptsService } from './login-attempts.service.js';
describe('LoginAttemptsService', () => {
let svc: LoginAttemptsService;
beforeEach(() => {
vi.useFakeTimers();
svc = new LoginAttemptsService();
});
afterEach(() => vi.useRealTimers());
it('bloquea al usuario tras 5 fallos (sin distinguir mayúsculas)', () => {
for (let i = 0; i < 4; i++) svc.recordFailure('Ana', `10.0.0.${i}`);
expect(() => svc.assertAllowed('ana', '10.0.0.99')).not.toThrow();
svc.recordFailure('ANA', '10.0.0.5');
expect(() => svc.assertAllowed('ana', '10.0.0.99')).toThrow(/Demasiados intentos/);
});
it('un login exitoso limpia el contador del usuario', () => {
for (let i = 0; i < 4; i++) svc.recordFailure('ana', '10.0.0.1');
svc.recordSuccess('ana');
svc.recordFailure('ana', '10.0.0.1');
expect(() => svc.assertAllowed('ana', '10.0.0.1')).not.toThrow();
});
it('bloquea una IP que prueba muchos usuarios distintos', () => {
for (let i = 0; i < 20; i++) svc.recordFailure(`user${i}`, '10.0.0.7');
expect(() => svc.assertAllowed('otro', '10.0.0.7')).toThrow(/Demasiados intentos/);
expect(() => svc.assertAllowed('otro', '10.0.0.8')).not.toThrow();
});
it('el bloqueo se levanta tras 15 minutos', () => {
for (let i = 0; i < 5; i++) svc.recordFailure('ana', '10.0.0.1');
expect(() => svc.assertAllowed('ana', '10.0.0.1')).toThrow();
vi.advanceTimersByTime(16 * 60_000);
expect(() => svc.assertAllowed('ana', '10.0.0.1')).not.toThrow();
});
});
@@ -0,0 +1,72 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
const WINDOW_MS = 15 * 60_000;
const MAX_FAILURES_PER_USER = 5;
const MAX_FAILURES_PER_IP = 20;
interface Counter {
count: number;
firstAt: number;
}
/**
* Freno a la fuerza bruta en el login: bloquea temporalmente tras varios
* intentos fallidos, por usuario y por IP. En memoria (proceso único).
*/
@Injectable()
export class LoginAttemptsService {
private readonly byUser = new Map<string, Counter>();
private readonly byIp = new Map<string, Counter>();
assertAllowed(username: string, ip: string): void {
const retry = Math.max(
this.retryAfterMs(this.byUser, username.toLowerCase(), MAX_FAILURES_PER_USER),
this.retryAfterMs(this.byIp, ip, MAX_FAILURES_PER_IP),
);
if (retry > 0) {
throw new HttpException(
{
statusCode: 429,
message: 'Demasiados intentos fallidos. Intente de nuevo más tarde.',
retryAfterSeconds: Math.ceil(retry / 1000),
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
recordFailure(username: string, ip: string): void {
this.bump(this.byUser, username.toLowerCase());
this.bump(this.byIp, ip);
}
recordSuccess(username: string): void {
this.byUser.delete(username.toLowerCase());
}
private bump(map: Map<string, Counter>, key: string): void {
const now = Date.now();
const current = map.get(key);
if (!current || now - current.firstAt > WINDOW_MS) {
map.set(key, { count: 1, firstAt: now });
} else {
current.count++;
}
if (map.size > 10_000) this.prune(map, now);
}
private retryAfterMs(map: Map<string, Counter>, key: string, max: number): number {
const c = map.get(key);
if (!c) return 0;
const remaining = c.firstAt + WINDOW_MS - Date.now();
if (remaining <= 0) {
map.delete(key);
return 0;
}
return c.count >= max ? remaining : 0;
}
private prune(map: Map<string, Counter>, now: number): void {
for (const [k, c] of map) if (now - c.firstAt > WINDOW_MS) map.delete(k);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/** Marca un endpoint como accesible sin sesión (el guard es global). */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, vi } from 'vitest';
import type { AppConfigService } from '../config/app-config.service.js';
import type { AuthUser } from './auth.types.js';
import { SessionStoreService } from './session-store.service.js';
const config = { get: () => 30 } as unknown as AppConfigService;
const user = (webUser: string): AuthUser => ({ webUser, empId: 1, firstName: 'Ana', lastName: 'Pérez' });
const MIN = 60_000;
describe('SessionStoreService', () => {
let store: SessionStoreService;
beforeEach(() => {
vi.useFakeTimers();
store = new SessionStoreService(config);
});
afterEach(() => vi.useRealTimers());
it('crea un token que valida al usuario', () => {
const token = store.create(user('ana'));
expect(token.length).toBeGreaterThanOrEqual(43);
expect(store.validate(token)?.webUser).toBe('ana');
});
it('rechaza tokens desconocidos', () => {
expect(store.validate('nope')).toBeUndefined();
});
it('un nuevo login invalida el token anterior del mismo usuario', () => {
const t1 = store.create(user('ana'));
const t2 = store.create(user('ana'));
expect(store.validate(t1)).toBeUndefined();
expect(store.validate(t2)).toBeDefined();
expect(store.size).toBe(1);
});
it('el timeout es deslizante: la actividad lo renueva', () => {
const token = store.create(user('ana'));
vi.advanceTimersByTime(25 * MIN);
expect(store.validate(token)).toBeDefined();
vi.advanceTimersByTime(25 * MIN);
expect(store.validate(token)).toBeDefined(); // 50 min en total, pero con actividad
});
it('expira tras 30 min sin actividad', () => {
const token = store.create(user('ana'));
vi.advanceTimersByTime(31 * MIN);
expect(store.validate(token)).toBeUndefined();
expect(store.size).toBe(0);
});
it('sweep elimina las sesiones vencidas', () => {
store.create(user('ana'));
store.create(user('luis'));
vi.advanceTimersByTime(31 * MIN);
store.sweep();
expect(store.size).toBe(0);
});
it('remove cierra la sesión', () => {
const token = store.create(user('ana'));
store.remove(token);
expect(store.validate(token)).toBeUndefined();
});
});
@@ -0,0 +1,84 @@
import { randomBytes } from 'node:crypto';
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { AppConfigService } from '../config/app-config.service.js';
import type { AuthUser } from './auth.types.js';
interface WebSession {
user: AuthUser;
lastActivity: number;
}
/**
* Sesiones web en memoria (un único proceso). Una sesión por usuario: un nuevo
* login invalida el token anterior. El timeout es deslizante: cada petición
* válida renueva la actividad.
*/
@Injectable()
export class SessionStoreService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SessionStoreService.name);
private readonly byToken = new Map<string, WebSession>();
private readonly tokenByUser = new Map<string, string>();
private sweeper?: NodeJS.Timeout;
constructor(private readonly config: AppConfigService) {}
onModuleInit() {
this.sweeper = setInterval(() => this.sweep(), 60_000);
this.sweeper.unref();
}
onModuleDestroy() {
clearInterval(this.sweeper);
}
create(user: AuthUser): string {
const previous = this.tokenByUser.get(user.webUser);
if (previous) {
this.byToken.delete(previous);
this.logger.log(`Sesión anterior de ${user.webUser} invalidada (nuevo login)`);
}
const token = randomBytes(32).toString('base64url');
this.byToken.set(token, { user, lastActivity: Date.now() });
this.tokenByUser.set(user.webUser, token);
return token;
}
/** Devuelve el usuario y renueva la actividad; `undefined` si no existe o expiró. */
validate(token: string): AuthUser | undefined {
const session = this.byToken.get(token);
if (!session) return undefined;
const now = Date.now();
if (now - session.lastActivity > this.timeoutMs()) {
this.remove(token);
this.logger.log(`Sesión de ${session.user.webUser} expirada por inactividad`);
return undefined;
}
session.lastActivity = now;
return session.user;
}
remove(token: string): void {
const session = this.byToken.get(token);
if (!session) return;
this.byToken.delete(token);
if (this.tokenByUser.get(session.user.webUser) === token) {
this.tokenByUser.delete(session.user.webUser);
}
}
/** Elimina las sesiones vencidas que nadie volvió a usar. */
sweep(): void {
const limit = Date.now() - this.timeoutMs();
for (const [token, s] of this.byToken) {
if (s.lastActivity < limit) this.remove(token);
}
}
get size(): number {
return this.byToken.size;
}
private timeoutMs(): number {
return this.config.get('SESSION_TIMEOUT_MINUTES') * 60_000;
}
}
@@ -0,0 +1,19 @@
import { BadRequestException, type PipeTransform } from '@nestjs/common';
import type { ZodType } from 'zod';
/** Valida el cuerpo/parámetros con un esquema zod y devuelve el valor ya tipado. */
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
statusCode: 400,
message: 'Datos inválidos',
errors: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
});
}
return result.data;
}
}
+4 -2
View File
@@ -19,6 +19,8 @@ export const envSchema = z
.filter(Boolean),
),
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug', 'verbose']).default('log'),
// Inactividad tras la cual caduca la sesión web (deslizante).
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
// --- SAP Business One Service Layer ---
SAPB1_SL_URL: z
@@ -29,8 +31,8 @@ export const envSchema = z
SAPB1_SL_PASSWORD: z.string().min(1),
SAPB1_SL_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
// Peticiones simultáneas permitidas hacia el Service Layer; el resto se encola.
// Medido en SAP10: con más de ~2 en paralelo el Service Layer retrasa ~7 s cada
// petición extra, mientras que en serie cada una tarda ~60 ms. Default: 1.
// Medido en SAP10 (12 peticiones): con 1 -> 0.6 s en total; con 2 ya tarda 7.5 s y con
// 4, 22 s (el Service Layer retrasa ~7 s por cada petición paralela extra). Default: 1.
SAPB1_SL_MAX_CONCURRENCY: z.coerce.number().int().positive().default(1),
// CA adicional de confianza (PEM): certificado autofirmado del Service
// Layer o CA de un firewall que inspecciona SSL. Opcional.
+3 -1
View File
@@ -1,4 +1,5 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/public.decorator.js';
import { AppConfigService } from '../config/app-config.service.js';
import { SapSessionService } from '../sap/sap-session.service.js';
@@ -9,12 +10,13 @@ export class HealthController {
private readonly config: AppConfigService,
) {}
@Public()
@Get()
check() {
return { status: 'ok', uptime: Math.round(process.uptime()) };
}
// TODO(auth): proteger con el guard de sesión cuando exista el login.
/** Requiere sesión web (guard global). */
@Get('sap')
async sap() {
await this.sessions.getSession();
+84
View File
@@ -0,0 +1,84 @@
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
process.env.SAPB1_SL_URL ??= 'https://sap.invalid:50000/b1s/v1';
process.env.SAPB1_SL_COMPANYDB ??= 'DB';
process.env.SAPB1_SL_USER ??= 'u';
process.env.SAPB1_SL_PASSWORD ??= 'p';
const EMPLOYEES = [
{ EmployeeID: 7, FirstName: 'Ana', LastName: 'Pérez', Active: 'tYES', U_WEB_USER: 'ana', U_WEB_PASS: 'secreto' },
];
describe('Auth (e2e, SAP simulado)', () => {
let app: INestApplication;
beforeEach(async () => {
const { AppModule } = await import('../src/app.module.js');
const { SapClientService } = await import('../src/sap/sap-client.service.js');
const { SapSessionService } = await import('../src/sap/sap-session.service.js');
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(SapClientService)
.useValue({
get: async (_path: string, q: { $filter: string }) => ({
data: { value: q.$filter === "U_WEB_USER eq 'ana'" ? EMPLOYEES : [] },
}),
})
.overrideProvider(SapSessionService)
.useValue({ getSession: async () => ({}), status: () => ({ connected: true }), onModuleDestroy: () => undefined })
.compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
await app.init();
});
afterEach(async () => {
await app.close();
});
const login = (username: string, password: string) =>
request(app.getHttpServer()).post('/api/auth/login').send({ username, password });
it('/health es público, /health/sap exige sesión', async () => {
await request(app.getHttpServer()).get('/api/health').expect(200);
await request(app.getHttpServer()).get('/api/health/sap').expect(401);
});
it('flujo completo: login -> me -> ruta protegida -> logout -> 401', async () => {
const res = await login('ana', 'secreto').expect(200);
expect(res.body.user).toMatchObject({ webUser: 'ana', empId: 7, firstName: 'Ana' });
expect(JSON.stringify(res.body)).not.toContain('secreto');
const auth = { Authorization: `Bearer ${res.body.token}` };
const me = await request(app.getHttpServer()).get('/api/auth/me').set(auth).expect(200);
expect(me.body.user.webUser).toBe('ana');
await request(app.getHttpServer()).get('/api/health/sap').set(auth).expect(200);
await request(app.getHttpServer()).post('/api/auth/logout').set(auth).expect(204);
await request(app.getHttpServer()).get('/api/auth/me').set(auth).expect(401);
});
it('un segundo login invalida el token del primero', async () => {
const t1 = (await login('ana', 'secreto').expect(200)).body.token;
const t2 = (await login('ana', 'secreto').expect(200)).body.token;
await request(app.getHttpServer()).get('/api/auth/me').set('Authorization', `Bearer ${t1}`).expect(401);
await request(app.getHttpServer()).get('/api/auth/me').set('Authorization', `Bearer ${t2}`).expect(200);
});
it('credenciales inválidas -> 401 y cuerpo inválido -> 400', async () => {
await login('ana', 'mala').expect(401);
await login('nadie', 'x').expect(401);
await request(app.getHttpServer()).post('/api/auth/login').send({ username: 'ana' }).expect(400);
});
it('sin token o con token falso -> 401', async () => {
await request(app.getHttpServer()).get('/api/auth/me').expect(401);
await request(app.getHttpServer()).get('/api/auth/me').set('Authorization', 'Bearer falso').expect(401);
});
it('tras 5 intentos fallidos responde 429', async () => {
for (let i = 0; i < 5; i++) await login('ana', 'mala').expect(401);
await login('ana', 'secreto').expect(429);
});
});