erp-core/docs/06-test-plans/TP-auth.md

793 lines
21 KiB
Markdown

# Plan de Pruebas - MGN-001 Auth
## Identificacion
| Campo | Valor |
|-------|-------|
| **ID** | TP-AUTH-001 |
| **Modulo** | MGN-001 |
| **Nombre** | Auth - Autenticacion |
| **Version** | 1.0 |
| **Estado** | En diseño |
| **Autor** | System |
| **Fecha** | 2025-12-05 |
---
## Alcance
### En Alcance
- Login con email y password
- Generacion y validacion de JWT tokens
- Refresh token y renovacion de sesion
- Logout individual y global
- Recuperacion de password
- Rate limiting
- Bloqueo de cuentas
### Fuera de Alcance
- Autenticacion OAuth/Social
- Two-Factor Authentication (2FA)
- Single Sign-On (SSO)
- Biometricos
---
## Estrategia de Pruebas
### Niveles de Prueba
| Nivel | Cobertura | Herramientas |
|-------|-----------|--------------|
| Unit Tests | 80% | Jest |
| Integration Tests | 70% | Jest + Supertest |
| E2E Tests | Casos criticos | Playwright/Cypress |
| Security Tests | OWASP Top 10 | OWASP ZAP, manual |
| Performance Tests | Endpoints criticos | k6, Artillery |
### Ambiente de Pruebas
| Ambiente | Proposito | Base de Datos |
|----------|-----------|---------------|
| Local | Desarrollo | PostgreSQL local |
| Test | CI/CD | PostgreSQL Docker |
| Staging | UAT | PostgreSQL Cloud |
---
## Casos de Prueba
### TC-AUTH-001: Login Exitoso
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-001 |
| **Tipo** | Funcional |
| **Prioridad** | Critica |
| **RF** | RF-AUTH-001 |
#### Precondiciones
- Usuario registrado con email "test@example.com"
- Password "SecurePass123!"
- Usuario activo (is_active = true)
- Cuenta no bloqueada
#### Pasos
| # | Accion | Datos | Resultado Esperado |
|---|--------|-------|-------------------|
| 1 | POST /api/v1/auth/login | `{"email": "test@example.com", "password": "SecurePass123!"}` | Status 200 |
| 2 | Verificar body | - | Contiene accessToken, refreshToken, user |
| 3 | Verificar cookies | - | Cookie httpOnly refresh_token establecida |
| 4 | Verificar session_history | - | Registro de login creado |
#### Codigo de Test
```typescript
describe('AuthController - Login', () => {
it('should login successfully with valid credentials', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'SecurePass123!',
})
.expect(200);
expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('refreshToken');
expect(response.body).toHaveProperty('user');
expect(response.body.user).toHaveProperty('id');
expect(response.body.user.email).toBe('test@example.com');
// Verify cookie
expect(response.headers['set-cookie']).toBeDefined();
expect(response.headers['set-cookie'][0]).toContain('refresh_token');
expect(response.headers['set-cookie'][0]).toContain('HttpOnly');
});
});
```
---
### TC-AUTH-002: Login con Credenciales Invalidas
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-002 |
| **Tipo** | Funcional / Seguridad |
| **Prioridad** | Critica |
| **RF** | RF-AUTH-001 |
#### Escenarios
| Escenario | Email | Password | Resultado |
|-----------|-------|----------|-----------|
| Email no existe | noexiste@test.com | cualquier | 401, "Credenciales invalidas" |
| Password incorrecto | test@example.com | wrongpass | 401, "Credenciales invalidas" |
| Email vacio | "" | pass | 400, "Email es requerido" |
| Password vacio | test@test.com | "" | 400, "Password es requerido" |
| Email invalido | "notanemail" | pass | 400, "Email invalido" |
| Password muy corto | test@test.com | "abc" | 400, "Password minimo 8 caracteres" |
#### Codigo de Test
```typescript
describe('AuthController - Login Failures', () => {
it('should return 401 for wrong password', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'wrongpassword123!',
})
.expect(401);
expect(response.body.message).toBe('Credenciales invalidas');
});
it('should return 401 for non-existent email without revealing', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'noexiste@example.com',
password: 'anypassword123!',
})
.expect(401);
// Mensaje identico para no revelar existencia de email
expect(response.body.message).toBe('Credenciales invalidas');
});
it('should return 400 for invalid email format', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'notanemail',
password: 'password123!',
})
.expect(400);
expect(response.body.message).toContain('Email');
});
});
```
---
### TC-AUTH-003: Bloqueo de Cuenta por Intentos Fallidos
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-003 |
| **Tipo** | Seguridad |
| **Prioridad** | Critica |
| **RF** | RF-AUTH-001 |
#### Pasos
| # | Accion | Resultado Esperado |
|---|--------|-------------------|
| 1 | Login con password incorrecto (1) | 401, failed_attempts = 1 |
| 2 | Login con password incorrecto (2) | 401, failed_attempts = 2 |
| 3 | Login con password incorrecto (3) | 401, failed_attempts = 3 |
| 4 | Login con password incorrecto (4) | 401, failed_attempts = 4 |
| 5 | Login con password incorrecto (5) | 401, failed_attempts = 5 |
| 6 | Login con password CORRECTO | 423, cuenta bloqueada |
| 7 | Esperar 30 minutos | - |
| 8 | Login con password correcto | 200, login exitoso |
#### Codigo de Test
```typescript
describe('AuthController - Account Lockout', () => {
it('should lock account after 5 failed attempts', async () => {
// 5 intentos fallidos
for (let i = 0; i < 5; i++) {
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'locktest@example.com',
password: 'wrongpassword',
})
.expect(401);
}
// 6to intento con credenciales correctas
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'locktest@example.com',
password: 'CorrectPass123!',
})
.expect(423);
expect(response.body.message).toContain('bloqueada');
});
it('should unlock account after 30 minutes', async () => {
// Mock time to simulate 30 minutes passing
jest.useFakeTimers();
jest.advanceTimersByTime(30 * 60 * 1000);
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'locktest@example.com',
password: 'CorrectPass123!',
})
.expect(200);
jest.useRealTimers();
});
});
```
---
### TC-AUTH-004: Generacion de JWT Token
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-004 |
| **Tipo** | Funcional |
| **Prioridad** | Critica |
| **RF** | RF-AUTH-002 |
#### Verificaciones
| Verificacion | Valor Esperado |
|--------------|----------------|
| Algoritmo | RS256 |
| Issuer (iss) | "erp-core" |
| Audience (aud) | "erp-api" |
| Access Token exp | 15 minutos |
| Refresh Token exp | 7 dias |
| Contiene sub | User ID |
| Contiene tid | Tenant ID |
| Contiene roles | Array de roles |
| Contiene jti | Unique ID |
#### Codigo de Test
```typescript
describe('TokenService', () => {
it('should generate valid JWT with correct claims', async () => {
const user = {
id: 'user-uuid',
tenantId: 'tenant-uuid',
email: 'test@example.com',
roles: [{ name: 'admin' }],
};
const tokens = await tokenService.generateTokenPair(user, {
ipAddress: '127.0.0.1',
userAgent: 'test',
});
// Decode access token
const decoded = jwt.decode(tokens.accessToken, { complete: true });
expect(decoded.header.alg).toBe('RS256');
expect(decoded.payload.iss).toBe('erp-core');
expect(decoded.payload.aud).toBe('erp-api');
expect(decoded.payload.sub).toBe('user-uuid');
expect(decoded.payload.tid).toBe('tenant-uuid');
expect(decoded.payload.roles).toContain('admin');
expect(decoded.payload.jti).toBeDefined();
// Verify expiration
const exp = new Date(decoded.payload.exp * 1000);
const now = new Date();
const diffMinutes = (exp.getTime() - now.getTime()) / 1000 / 60;
expect(diffMinutes).toBeCloseTo(15, 0);
});
it('should verify token signature with public key', async () => {
const tokens = await tokenService.generateTokenPair(mockUser, mockMetadata);
const verified = jwt.verify(tokens.accessToken, publicKey, {
algorithms: ['RS256'],
});
expect(verified).toBeDefined();
expect(verified.sub).toBe(mockUser.id);
});
});
```
---
### TC-AUTH-005: Refresh Token
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-005 |
| **Tipo** | Funcional |
| **Prioridad** | Critica |
| **RF** | RF-AUTH-003 |
#### Escenarios
| Escenario | Resultado Esperado |
|-----------|-------------------|
| Refresh con token valido | 200, nuevos tokens |
| Refresh con token expirado | 401, "Refresh token expirado" |
| Refresh con token revocado | 401, "Token revocado" |
| Refresh con token ya usado | 401, "Sesion comprometida" |
| Refresh sin token | 400, "Refresh token requerido" |
#### Codigo de Test
```typescript
describe('AuthController - Refresh Token', () => {
it('should refresh tokens successfully', async () => {
// Login first
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'SecurePass123!' });
const refreshToken = loginResponse.body.refreshToken;
// Refresh
const response = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.send({ refreshToken })
.expect(200);
expect(response.body.accessToken).toBeDefined();
expect(response.body.refreshToken).toBeDefined();
expect(response.body.accessToken).not.toBe(loginResponse.body.accessToken);
expect(response.body.refreshToken).not.toBe(refreshToken);
});
it('should detect token replay attack', async () => {
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'SecurePass123!' });
const originalRefreshToken = loginResponse.body.refreshToken;
// First refresh (valid)
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.send({ refreshToken: originalRefreshToken })
.expect(200);
// Second refresh with same token (replay attack)
const response = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.send({ refreshToken: originalRefreshToken })
.expect(401);
expect(response.body.message).toContain('comprometida');
});
});
```
---
### TC-AUTH-006: Logout
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-006 |
| **Tipo** | Funcional |
| **Prioridad** | Alta |
| **RF** | RF-AUTH-004 |
#### Codigo de Test
```typescript
describe('AuthController - Logout', () => {
it('should logout successfully', async () => {
// Login
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'SecurePass123!' });
const accessToken = loginResponse.body.accessToken;
// Logout
const response = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(response.body.message).toContain('cerrada');
// Verify cookie is cleared
expect(response.headers['set-cookie'][0]).toContain('Max-Age=0');
// Verify token is blacklisted
const protectedResponse = await request(app.getHttpServer())
.get('/api/v1/protected-resource')
.set('Authorization', `Bearer ${accessToken}`)
.expect(401);
});
it('should logout all sessions', async () => {
// Create multiple sessions
const session1 = await login('test@example.com', 'SecurePass123!');
const session2 = await login('test@example.com', 'SecurePass123!');
const session3 = await login('test@example.com', 'SecurePass123!');
// Logout all
const response = await request(app.getHttpServer())
.post('/api/v1/auth/logout-all')
.set('Authorization', `Bearer ${session1.accessToken}`)
.expect(200);
expect(response.body.sessionsRevoked).toBe(3);
// All sessions should be invalid
for (const session of [session1, session2, session3]) {
await request(app.getHttpServer())
.get('/api/v1/protected-resource')
.set('Authorization', `Bearer ${session.accessToken}`)
.expect(401);
}
});
});
```
---
### TC-AUTH-007: Password Recovery
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-007 |
| **Tipo** | Funcional |
| **Prioridad** | Alta |
| **RF** | RF-AUTH-005 |
#### Codigo de Test
```typescript
describe('AuthController - Password Recovery', () => {
it('should send recovery email for existing user', async () => {
const emailSpy = jest.spyOn(emailService, 'sendPasswordResetEmail');
const response = await request(app.getHttpServer())
.post('/api/v1/auth/password/request-reset')
.send({ email: 'test@example.com' })
.expect(200);
expect(response.body.message).toContain('Si el email esta registrado');
expect(emailSpy).toHaveBeenCalledWith(
'test@example.com',
expect.any(String),
expect.any(String),
);
});
it('should return same message for non-existent email', async () => {
const emailSpy = jest.spyOn(emailService, 'sendPasswordResetEmail');
const response = await request(app.getHttpServer())
.post('/api/v1/auth/password/request-reset')
.send({ email: 'noexiste@example.com' })
.expect(200);
// Same message to not reveal email existence
expect(response.body.message).toContain('Si el email esta registrado');
expect(emailSpy).not.toHaveBeenCalled();
});
it('should reset password with valid token', async () => {
// Request reset
await request(app.getHttpServer())
.post('/api/v1/auth/password/request-reset')
.send({ email: 'test@example.com' });
// Get token from DB (in real test, from email mock)
const resetToken = await getResetTokenForUser('test@example.com');
// Reset password
const response = await request(app.getHttpServer())
.post('/api/v1/auth/password/reset')
.send({
token: resetToken,
newPassword: 'NewSecurePass456!',
confirmPassword: 'NewSecurePass456!',
})
.expect(200);
// Verify can login with new password
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'NewSecurePass456!',
})
.expect(200);
});
it('should reject expired reset token', async () => {
// Create expired token
const expiredToken = await createExpiredResetToken('test@example.com');
const response = await request(app.getHttpServer())
.post('/api/v1/auth/password/reset')
.send({
token: expiredToken,
newPassword: 'NewSecurePass456!',
confirmPassword: 'NewSecurePass456!',
})
.expect(400);
expect(response.body.message).toContain('expirado');
});
it('should reject reused password', async () => {
// Set up user with password history
const user = await createUserWithPasswordHistory();
const resetToken = await createResetToken(user.email);
const response = await request(app.getHttpServer())
.post('/api/v1/auth/password/reset')
.send({
token: resetToken,
newPassword: user.previousPasswords[0], // Try old password
confirmPassword: user.previousPasswords[0],
})
.expect(400);
expect(response.body.message).toContain('anterior');
});
});
```
---
### TC-AUTH-008: Rate Limiting
| Campo | Valor |
|-------|-------|
| **ID** | TC-AUTH-008 |
| **Tipo** | Seguridad / Performance |
| **Prioridad** | Alta |
| **RF** | Todos |
#### Limites por Endpoint
| Endpoint | Limite | Ventana |
|----------|--------|---------|
| POST /auth/login | 10 | 1 minuto |
| POST /auth/refresh | 1 | 1 segundo |
| POST /auth/logout | 10 | 1 minuto |
| POST /password/request-reset | 3 | 1 hora |
| POST /password/reset | 5 | 1 hora |
#### Codigo de Test
```typescript
describe('Rate Limiting', () => {
it('should limit login attempts to 10 per minute', async () => {
// Make 10 requests
for (let i = 0; i < 10; i++) {
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'wrongpass' });
}
// 11th request should be rate limited
const response = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'wrongpass' })
.expect(429);
expect(response.body.message).toContain('Too many requests');
});
it('should limit password reset requests to 3 per hour', async () => {
for (let i = 0; i < 3; i++) {
await request(app.getHttpServer())
.post('/api/v1/auth/password/request-reset')
.send({ email: 'test@example.com' });
}
const response = await request(app.getHttpServer())
.post('/api/v1/auth/password/request-reset')
.send({ email: 'test@example.com' })
.expect(429);
});
});
```
---
## Pruebas de Seguridad
### OWASP Top 10 Checklist
| # | Vulnerabilidad | Test | Estado |
|---|----------------|------|--------|
| A01 | Broken Access Control | Token validation en cada request | [ ] |
| A02 | Cryptographic Failures | JWT RS256, bcrypt | [ ] |
| A03 | Injection | SQL injection en login | [ ] |
| A04 | Insecure Design | Token replay detection | [ ] |
| A05 | Security Misconfiguration | Headers de seguridad | [ ] |
| A06 | Vulnerable Components | Audit de dependencias | [ ] |
| A07 | Authentication Failures | Bloqueo, rate limiting | [ ] |
| A08 | Integrity Failures | Firma de tokens | [ ] |
| A09 | Logging Failures | Auditoria de eventos | [ ] |
| A10 | SSRF | N/A para este modulo | [ ] |
### Pruebas de Penetracion
| Test | Descripcion | Resultado Esperado |
|------|-------------|-------------------|
| SQL Injection en email | `' OR '1'='1` | 400, sanitizado |
| XSS en email | `<script>alert(1)</script>` | 400, sanitizado |
| JWT tampering | Modificar payload | 401, firma invalida |
| Token brute force | Intentar adivinar token | Rate limited |
| Session fixation | Reusar token post-login | Token rotado |
---
## Pruebas de Performance
### Load Test con k6
```javascript
// k6 script
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up
{ duration: '3m', target: 50 }, // Stay
{ duration: '1m', target: 100 }, // Peak
{ duration: '1m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% requests < 500ms
http_req_failed: ['rate<0.01'], // Error rate < 1%
},
};
export default function () {
const loginRes = http.post(
'http://localhost:3000/api/v1/auth/login',
JSON.stringify({
email: `user${__VU}@example.com`,
password: 'TestPass123!',
}),
{ headers: { 'Content-Type': 'application/json' } }
);
check(loginRes, {
'login status is 200': (r) => r.status === 200,
'login has access token': (r) => r.json('accessToken') !== undefined,
});
sleep(1);
}
```
### Metricas Objetivo
| Endpoint | P50 | P95 | P99 |
|----------|-----|-----|-----|
| POST /auth/login | 200ms | 500ms | 1000ms |
| POST /auth/refresh | 50ms | 200ms | 500ms |
| POST /auth/logout | 50ms | 200ms | 500ms |
---
## Matriz de Trazabilidad
| RF | Casos de Prueba |
|----|-----------------|
| RF-AUTH-001 | TC-AUTH-001, TC-AUTH-002, TC-AUTH-003 |
| RF-AUTH-002 | TC-AUTH-004 |
| RF-AUTH-003 | TC-AUTH-005 |
| RF-AUTH-004 | TC-AUTH-006 |
| RF-AUTH-005 | TC-AUTH-007 |
| Rate Limiting | TC-AUTH-008 |
---
## Criterios de Salida
### Para Desarrollo
- [ ] Cobertura de codigo > 80%
- [ ] Todos los tests unitarios pasan
- [ ] Tests de integracion pasan
- [ ] Analisis estatico sin errores criticos
### Para QA
- [ ] Todos los casos de prueba ejecutados
- [ ] 0 bugs criticos
- [ ] 0 bugs mayores
- [ ] Pruebas de seguridad completadas
- [ ] Pruebas de performance completadas
### Para Release
- [ ] UAT aprobado
- [ ] Security review aprobado
- [ ] Performance baselines establecidos
- [ ] Documentacion actualizada
---
## Reportes
### Template de Reporte de Bugs
```markdown
## Bug Report
**ID:** BUG-AUTH-XXX
**Titulo:** [Descripcion corta]
**Severidad:** Critica/Mayor/Menor/Trivial
**Componente:** Auth Module
**RF:** RF-AUTH-XXX
### Pasos para Reproducir
1. ...
2. ...
3. ...
### Resultado Actual
[Que sucede]
### Resultado Esperado
[Que deberia suceder]
### Evidencia
[Screenshots, logs]
### Ambiente
- OS:
- Browser:
- Backend version:
```
---
## Historial de Cambios
| Version | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-12-05 | System | Creacion inicial |
---
## Aprobaciones
| Rol | Nombre | Fecha | Firma |
|-----|--------|-------|-------|
| QA Lead | - | - | [ ] |
| Tech Lead | - | - | [ ] |
| Security | - | - | [ ] |