From 7fadb88da00aa6e25e74302da1b9059c0d055af3 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 14 Apr 2026 13:52:59 -0300 Subject: [PATCH] =?UTF-8?q?docs(web):=20smoke=20test=20checklist=20UDT-002?= =?UTF-8?q?=20=E2=80=94=20login,=20refresh,=20logout,=20reuse=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/smoke-test-udt-002.md | 108 +++++++++++++++++++++ src/web/src/tests/stores/authStore.test.ts | 10 +- 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 docs/smoke-test-udt-002.md diff --git a/docs/smoke-test-udt-002.md b/docs/smoke-test-udt-002.md new file mode 100644 index 0000000..ce36c08 --- /dev/null +++ b/docs/smoke-test-udt-002.md @@ -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": "", "refreshToken": ""}' + ``` +- [ ] 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": "", "refreshToken": ""}' + ``` +- [ ] 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` | diff --git a/src/web/src/tests/stores/authStore.test.ts b/src/web/src/tests/stores/authStore.test.ts index 6fbb09d..fdc3b95 100644 --- a/src/web/src/tests/stores/authStore.test.ts +++ b/src/web/src/tests/stores/authStore.test.ts @@ -172,8 +172,8 @@ describe('authStore', () => { }) }) - describe('legacy logout compatibility', () => { - it('clears user and accessToken from state', async () => { + describe('legacy logout compatibility (via clearAuth)', () => { + it('clearAuth clears user and accessToken from state', () => { useAuthStore.getState().setAuth({ user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, accessToken: 'some-token', @@ -181,14 +181,14 @@ describe('authStore', () => { expiresIn: 3600, }) - await useAuthStore.getState().logout() + useAuthStore.getState().clearAuth() const state = useAuthStore.getState() expect(state.user).toBeNull() expect(state.accessToken).toBeNull() }) - it('removes auth-storage from localStorage on logout', async () => { + it('clearAuth removes auth-storage from localStorage', () => { useAuthStore.getState().setAuth({ user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, accessToken: 'some-token', @@ -196,7 +196,7 @@ describe('authStore', () => { expiresIn: 3600, }) - await useAuthStore.getState().logout() + useAuthStore.getState().clearAuth() const stored = localStorage.getItem('auth-storage') if (stored !== null) {