UDT-002: Logout + Refresh Token con rotación y chain revocation #3

Merged
dmolinari merged 36 commits from feature/UDT-002 into main 2026-04-14 17:37:47 +00:00
2 changed files with 113 additions and 5 deletions
Showing only changes of commit 7fadb88da0 - Show all commits

108
docs/smoke-test-udt-002.md Normal file
View File

@@ -0,0 +1,108 @@
# Smoke Test — UDT-002: Logout + Refresh Token
**Branch**: feature/UDT-002
**Fecha**: 2026-04-14
**Prerequisito**: backend corriendo en `http://localhost:5212`, BD `SIGCM2` con migración V002 aplicada.
---
## Escenario 1 — Login y persistencia de tokens
- [ ] Abrir la app en `http://localhost:5173`
- [ ] Ingresar con credenciales válidas (admin / password)
- [ ] Verificar que el login redirige al home
- [ ] Abrir DevTools → Application → Local Storage → `auth-storage`
- [ ] Confirmar que el objeto contiene: `accessToken`, `refreshToken`, `expiresAt`, `user`
- [ ] Verificar que `expiresAt` es aproximadamente `Date.now() + 3600000` (1 hora)
---
## Escenario 2 — Refresh transparente en 401
**Opción A (esperar expiración natural — requiere token con TTL corto):**
- [ ] Modificar `Jwt:AccessTokenMinutes` a `1` en `appsettings.Development.json` y reiniciar el backend
- [ ] Hacer login
- [ ] Esperar 1 minuto para que el access token expire
- [ ] Realizar cualquier request autenticado (ej: navegar a una sección que llame a la API)
- [ ] Verificar que el request se completa sin error visible para el usuario
- [ ] Verificar en DevTools → Network que hubo una llamada a `POST /api/v1/auth/refresh` seguida del request original reenviado con un nuevo Bearer
**Opción B (manipulación manual del token):**
- [ ] Después del login, abrir DevTools → Application → Local Storage → `auth-storage`
- [ ] Editar el JSON y reemplazar `accessToken` con un valor inválido (ej: `"expired"`)
- [ ] Realizar cualquier request autenticado
- [ ] El interceptor de axiosClient recibe 401, llama a `/refresh` con el `refreshToken` real
- [ ] El request original se reintenta automáticamente con el nuevo `accessToken`
- [ ] El usuario no ve ningún error
---
## Escenario 3 — Refresh de 3 requests paralelos (singleton promise)
- [ ] Con el access token vencido (opción B del escenario 2)
- [ ] Abrir una página que dispare múltiples llamadas API simultáneas
- [ ] Verificar en DevTools → Network que hay exactamente **1** llamada a `POST /api/v1/auth/refresh`
- [ ] Verificar que todos los requests subsiguientes retornan con éxito
---
## Escenario 4 — Logout
- [ ] Con sesión activa, hacer click en el botón de logout
- [ ] Verificar que redirige a `/login`
- [ ] Verificar en DevTools → Network que se llamó a `POST /api/v1/auth/logout`
- [ ] Verificar en Local Storage que `auth-storage` tiene `user: null`, `accessToken: null`, `refreshToken: null`
- [ ] Intentar navegar a una ruta protegida — debería redirigir a login
---
## Escenario 5 — Reuso de refresh token después del logout (reuse detection)
- [ ] Hacer login y copiar el valor de `refreshToken` del Local Storage
- [ ] Hacer logout
- [ ] Intentar llamar manualmente al endpoint de refresh con el token anterior:
```bash
curl -X POST http://localhost:5212/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"accessToken": "<access-anterior>", "refreshToken": "<refresh-anterior>"}'
```
- [ ] Verificar que el backend responde `401` con `{ "error": "invalid_token" }`
- [ ] Verificar en la BD que todos los tokens de la familia fueron revocados:
```sql
SELECT * FROM dbo.RefreshToken WHERE RevokedAt IS NOT NULL ORDER BY Id DESC;
```
---
## Escenario 6 — Refresh token expirado (7 días)
- [ ] Modificar `ExpiresAt` de un token en la BD `SIGCM2_Test` a una fecha pasada
- [ ] Intentar refresh con ese token — debería responder `401`
- [ ] Verificar que el frontend redirige a `/login` y limpia el Local Storage
---
## Escenario 7 — Refresh con access token de otro usuario (mismatch)
- [ ] Crear dos usuarios en la BD (o usar admin + otro)
- [ ] Hacer login con usuario A, guardar el `accessToken`
- [ ] Hacer login con usuario B, guardar el `refreshToken`
- [ ] Intentar refresh con accessToken de A + refreshToken de B:
```bash
curl -X POST http://localhost:5212/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"accessToken": "<access-usuario-A>", "refreshToken": "<refresh-usuario-B>"}'
```
- [ ] Verificar que el backend responde `401`
---
## Notas de verificación
| Check | Comando |
|-------|---------|
| Tokens en BD | `SELECT Id, UsuarioId, FamilyId, IssuedAt, ExpiresAt, RevokedAt FROM dbo.RefreshToken ORDER BY Id DESC` |
| Familias revocadas | `SELECT FamilyId, COUNT(*) as Total, SUM(CASE WHEN RevokedAt IS NOT NULL THEN 1 ELSE 0 END) as Revoked FROM dbo.RefreshToken GROUP BY FamilyId` |
| Usuario activo | `SELECT Id, Username, Activo FROM dbo.Usuario` |

View File

@@ -172,8 +172,8 @@ describe('authStore', () => {
}) })
}) })
describe('legacy logout compatibility', () => { describe('legacy logout compatibility (via clearAuth)', () => {
it('clears user and accessToken from state', async () => { it('clearAuth clears user and accessToken from state', () => {
useAuthStore.getState().setAuth({ useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token', accessToken: 'some-token',
@@ -181,14 +181,14 @@ describe('authStore', () => {
expiresIn: 3600, expiresIn: 3600,
}) })
await useAuthStore.getState().logout() useAuthStore.getState().clearAuth()
const state = useAuthStore.getState() const state = useAuthStore.getState()
expect(state.user).toBeNull() expect(state.user).toBeNull()
expect(state.accessToken).toBeNull() expect(state.accessToken).toBeNull()
}) })
it('removes auth-storage from localStorage on logout', async () => { it('clearAuth removes auth-storage from localStorage', () => {
useAuthStore.getState().setAuth({ useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token', accessToken: 'some-token',
@@ -196,7 +196,7 @@ describe('authStore', () => {
expiresIn: 3600, expiresIn: 3600,
}) })
await useAuthStore.getState().logout() useAuthStore.getState().clearAuth()
const stored = localStorage.getItem('auth-storage') const stored = localStorage.getItem('auth-storage')
if (stored !== null) { if (stored !== null) {