Saltar a contenido

📚 Unidad ISS-15 · Feature Session (login y perfil) — capa 🧠 APRENDER

🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación

Capa Página Para qué
🧠 Aprender esta página comprender, explicar y relacionar
🛠 Construir Feature Session (login y perfil) ejecutar, programar y verificar
✅ GATE Cierre de la unidad condición para pasar al bloque siguiente

Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas de la unidad; los enlaces apuntan a secciones reales del material (anclas de MkDocs, sin acentos).

Tema 🧠 Aprender (esta página) 🛠 Construir (ISS técnico)
Ruta y ficha de la unidad Ruta de aprendizaje · Ficha del ISS Contenido de la unidad
Mapas y estructura Mapa mental · Mapa del backend · Árbol de archivos Contenido de la unidad
Recorrido y comandos Comandos explicados · Recorrido paso a paso Contenido de la unidad
Diagnóstico Diagnóstico Criterios de aceptación
Evaluación y cierre Criterios · Evaluación · GATE Condiciones de cierre · Cierre

🖥 Presentación del ISS

Presentación de la unidad. Diapositivas de ISS-15 — Feature Session: Inicio de Sesión, Registro y Perfil (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

⛶ Ver presentación completa ⬇ Archivo editable (.pptx)

8 diapositivas · se visualiza dentro del sitio (archivo editable .pptx como opción secundaria).


🎬 Video explicativo

Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, el inicio de sesión del ISS-15. Usuario ausente, inactivo o contraseña mala responden el mismo 401. El archivo de rutas de sesión existe; montarlo en la app es el cierre de fase, no esta unidad.

5:05 · narración en español · subtítulos activables desde el reproductor.


ISS-15 — Cuaderno de aprendizaje visual

Tema

Feature Session: exponer el punto de entrada al sistema (login), la renovación automática (refresh con rotación), la salida (logout), el perfil del usuario y sus permisos efectivos, demostrando que las tres modalidades de acceso (OPEN, JWT y JWT + RBAC) conviven sin fricción.

Fuente técnica autoritativa

Archivo fuente ../manual/17-ISS-15-auth-session.md
Estado Solo lectura — este cuaderno no modifica el ISS
Alcance Feature features/auth/session/: DTOs, service, controller, rutas, Swagger y pruebas .http; cierre con verificación E2E de las tres modalidades

El ISS manda; el cuaderno explica. Todo el contenido técnico del ISS (código, rutas, credenciales y criterios) aparece aquí íntegro y verbatim más abajo, en la sección Recorrido del ISS, paso a paso. Lo único que añade este cuaderno es el por qué.

Pregunta que responde: ¿de dónde sale cada dato técnico de este cuaderno?

Regla del ISS

Objetivo: exponer el punto de entrada al sistema (login), la renovación automática (refresh con rotación), la salida (logout), el perfil del usuario y sus permisos efectivos. Es el feature que demuestra que las tres modalidades conviven sin fricción. Bloqueado por: ISS-14.

La condición que el propio ISS exige se recoge en su DoD: las tres modalidades deben ser observables con curl (401 sin token, 403 sin concesión, 200/201 con ella), GET /api/permisos debe reflejar la matriz real (58 para admin, 7 para seller), la rotación de refresh debe invalidar el token anterior, y npx tsc --noEmit debe pasar sin errores con npm run dev arrancando.

Pregunta que responde: ¿qué tiene que pasar para poder dar este ISS por bueno?

Cómo leer este cuaderno

Cada concepto se presenta tres veces, desde tres ángulos distintos:

                 CONCEPTO
                    │
        ┌───────────┼───────────┐
        ▼           ▼           ▼
   EXPLICACIÓN    CÓDIGO      VISUAL
        │           │           │
    ¿qué es?    ¿dónde está?  ¿cómo lo
    ¿por qué?   ¿qué hace?     visualizo?
    ¿para qué?  ¿cómo opera?  ¿con qué
                               se relaciona?

Pregunta que responde: ¿cómo está organizado este cuaderno y por qué se enseña todo tres veces?

El recorrido de lectura es siempre el mismo:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

Pregunta que responde: ¿en qué orden recorro cada concepto dentro del cuaderno?

Y cada cuaderno contiene los mismos seis componentes:

Componente Dónde vive Para qué sirve
Texto todas las secciones entender el por qué
Código Recorrido del ISS ver el qué exacto (verbatim del ISS)
Diagramas Mapa mental, Mapa del backend, Flujos ver el cómo se conecta
Preguntas Evaluación comprobar que entendiste
Evaluación Evaluación practicar y autoevaluarte
GATE GATE saber si puedes pasar al siguiente ISS

Ruta de aprendizaje

Esta ruta es específica de ISS-15: primero los DTOs, después el service (el corazón del ciclo de vida), luego controller y rutas, y al final Swagger, pruebas HTTP y verificación E2E.

Escribir los DTOs (login, refresh, logout, respuesta, index)
    ↓
Escribir session.service.ts (login, refresh, logout, profile, myPermissions)
    ↓
Escribir session.controller.ts (this.run + deviceInfo)
    ↓
Escribir session.routes.ts (OPEN + JWT en un archivo)
    ↓
Escribir session.swagger.ts (openSecurity / bearerSecurity)
    ↓
Escribir los .http (login, refresh, perfil)
    ↓
Verificación E2E con curl
    ↓
npx tsc --noEmit y npm run dev
    ↓
Verificar (GATE)

Pregunta que responde: ¿cuál es el camino concreto que sigo para completar este ISS?

Índice

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. Árbol de archivos
  5. Anatomía del código
  6. Flujos
  7. Comandos explicados
  8. Recorrido del ISS, paso a paso
  9. Diagnóstico
  10. Conexión con el resto del curso
  11. Criterios de aceptación
  12. Evaluación
  13. Glosario
  14. GATE
  15. Anexo — Pruebas E2E de los endpoints de Business bajo las 3 modalidades de acceso

Ficha del ISS

Campo Valor
ISS ISS-15
Título Feature Session (login, refresh, logout, perfil y permisos)
Objetivo Exponer el punto de entrada (login), la renovación automática (refresh con rotación), la salida (logout), el perfil y los permisos efectivos del usuario
Fase Fase II — Auth con RBAC
Tecnología principal Express 5 + TypeScript + Sequelize; JWT HS256 y refresh tokens con rotación
Depende de ISS-14 — Feature RefreshTokens
Habilita Cierre de Fase II
Archivos creados session/dto/{login,refresh-session,logout-session,session-response,index}.ts, session.service.ts, session.controller.ts, session.routes.ts, session.swagger.ts, session/http/{session.login,session.refresh,session.profile}.http
Archivos parcheados Ninguno en este ISS (el ISS solo enumera altas dentro de features/auth/session/)
Componentes incorporados Feature Session: DTOs + SessionService + SessionController + SessionRoutes + módulo Swagger + pruebas .http
Verificación principal Recorrido E2E que prueba OPEN → JWT → JWT + RBAC (401 / 403 / 200-201) y npx tsc --noEmit
Resultado esperado Login, refresh (con rotación), logout, perfil y permisos funcionando; las tres modalidades observables con curl
GATE npx tsc --noEmit sin errores, npm run db:seed && npm run dev, y las comprobaciones E2E en verde

Qué implementamos AHORA

  • DTOs del feature: LoginDto, RefreshSessionDto, LogoutSessionDto, SessionTokensDto y ProfileDto, más el barril index.ts.
  • El SessionService, que orquesta piezas ya construidas: autentica (login), rota (refresh), revoca (logout), devuelve identidad (profile) y lista permisos (myPermissions).
  • El SessionController con this.run(res, …) y el helper deviceInfo que lee el User-Agent.
  • Las rutas donde conviven las tres modalidades: login/refresh/logout OPEN y perfil/permisos JWT.
  • El módulo Swagger sessionSwagger con openSecurity en las OPEN y bearerSecurity en las JWT.
  • Los archivos .http de login, refresh y perfil, y la verificación E2E documentada.

Qué todavía NO implementamos

No se implementa aquí Llega en
La base de seguridad (JWT HS256, bcrypt 12, AppError, sendError) ISS-09
Feature users (hash, cambio de contraseña, permisos efectivos) ISS-10
Features roles y resources (catálogo de 58 recursos) ISS-11
role_users y resource_roles (la matriz RBAC) ISS-12
Middlewares authenticate / authorize ISS-13
refresh_tokens (hash SHA-256, rotación, detección de reúso) ISS-14
El registro de SessionRoutes en el enrutador de la App Cierre de Fase II
Autorización granular (authorize) en las propias rutas de sesión No aplica: los puntos de acceso previos a la matriz no la llevan

Ojo con la trampa habitual: POST /api/sesion/refresh y POST /api/sesion/logout son OPEN de facto, pero no están «abiertos»: exigen el refresh token en el cuerpo. OPEN significa «sin identidad previa (sin access token)», no «sin credencial».

Mapa mental del ISS

mindmap
  root((ISS-15<br/>Feature Session))
    Objetivo
      Punto de entrada login
      Renovacion refresh
      Salida logout
      Perfil y permisos
    Service
      login
      refresh
      logout
      profile
      myPermissions
    DTOs
      LoginDto
      RefreshSessionDto
      LogoutSessionDto
      SessionTokensDto
    Rutas
      OPEN login refresh logout
      JWT perfil permisos
    Seguridad
      Rotacion de refresh
      Deteccion de reuso
      Ventana deslizante
      Mismo 401 generico
    Pruebas
      Archivos http
      Recorrido E2E con curl
    GATE
      tsc noEmit
      npm run dev

Pregunta que responde: ¿de qué trata este ISS y qué piezas lo componen?

Mapa del backend

Esta representación responde siempre a la misma pregunta: ¿en qué parte del backend estamos?

IMPLEMENTADO HASTA ESTE ISS
───────────────────────────

   HTTP → Express App                                        ✅
        ↓
   Routes → Controller → Service → Repository → Model → DTO  ✅
        ↓
   Sequelize → BD                                            ✅
   Seeders                                                  ✅ (ISS-04)
   Swagger / api docs                                       ✅ (ISS-05)
   Fase I — Business (clients, product-types, products, sales) ✅
   Seguridad base, users, roles, resources, pivotes         ✅ (ISS-09 … ISS-12)
   Middlewares authenticate / authorize                     ✅ (ISS-13)
   refresh_tokens con rotación                              ✅ (ISS-14)

   🆕 Sesión (login / refresh / logout / perfil / permisos) ✅ (ISS-15)
      features/auth/session/
      ├── dto/ (LoginDto, RefreshSessionDto, LogoutSessionDto, SessionTokensDto, ProfileDto)
      ├── session.service.ts      (orquesta)
      ├── session.controller.ts
      ├── session.routes.ts       (OPEN + JWT)
      ├── session.swagger.ts
      └── http/ (login, refresh, profile)

   LAS TRES MODALIDADES DE ACCESO QUE CONVIVEN
   ├── OPEN          → POST /api/sesion/login
   │                   POST /api/sesion/refresh
   │                   POST /api/sesion/logout
   ├── JWT           → GET  /api/sesion/perfil
   │                   GET  /api/permisos
   └── JWT + RBAC    → rutas de negocio (ej. GET /api/clientes)

OBJETIVO DE ARQUITECTURA
────────────────────────

   🎯 Cierre de Fase II (18-cierre-auth.md)
   ⬜  Todo el laboratorio queda construido tras el cierre

Pregunta que responde: ¿qué capas del backend existen ya y cuáles siguen siendo objetivo?

Este es el ISS donde la arquitectura de seguridad se ve completa: las tres modalidades no se sustituyen entre sí, se suman en el mismo proyecto.

Árbol de archivos

Leyenda: ★ = creado o trabajado en este ISS · △ = existente parcheado en este ISS.

Estructura antes

src/
├── config/
├── database/
│   ├── db.ts
│   └── seeders/
├── features/
│   └── auth/
│       ├── access/                 (authenticate, authorize)
│       ├── refresh-tokens/
│       │   ├── refresh-token.model.ts
│       │   ├── refresh-tokens.repository.ts
│       │   └── refresh-tokens.service.ts
│       ├── resource-roles/
│       ├── role-users/
│       ├── roles/
│       ├── resources/
│       ├── users/
│       └── session/                ← todavía no existe
├── routes/
│   └── index.ts
├── shared/
│   ├── auth/
│   │   ├── auth-user.ts            (requireAuthUser)
│   │   ├── jwt.ts                  (signAccessToken, ACCESS_TOKEN_TTL_SECONDS)
│   │   └── password.ts             (comparePassword)
│   ├── errors/
│   │   └── app-error.ts            (AppError)
│   └── http/
│       ├── base-controller.ts      (BaseController.run)
│       └── swagger-security.ts     (openSecurity, bearerSecurity, …)
└── server.ts

Pregunta que responde: ¿qué archivos existían ya antes de empezar este ISS?

Archivos creados / modificados en este ISS

★ src/features/auth/session/dto/login.dto.ts
★ src/features/auth/session/dto/refresh-session.dto.ts
★ src/features/auth/session/dto/logout-session.dto.ts
★ src/features/auth/session/dto/session-response.dto.ts
★ src/features/auth/session/dto/index.ts
★ src/features/auth/session/session.service.ts
★ src/features/auth/session/session.controller.ts
★ src/features/auth/session/session.routes.ts
★ src/features/auth/session/session.swagger.ts
★ src/features/auth/session/http/session.login.http
★ src/features/auth/session/http/session.refresh.http
★ src/features/auth/session/http/session.profile.http

Pregunta que responde: ¿qué toca exactamente este ISS y con qué rol?

Estructura después

src/
├── features/
│   └── auth/
│       ├── access/
│       ├── refresh-tokens/
│       ├── resource-roles/
│       ├── role-users/
│       ├── roles/
│       ├── resources/
│       ├── users/
│       └── session/                ★ (nuevo)
│           ├── dto/                ★
│           │   ├── login.dto.ts
│           │   ├── refresh-session.dto.ts
│           │   ├── logout-session.dto.ts
│           │   ├── session-response.dto.ts
│           │   └── index.ts
│           ├── http/               ★
│           │   ├── session.login.http
│           │   ├── session.refresh.http
│           │   └── session.profile.http
│           ├── session.service.ts  ★
│           ├── session.controller.ts
│           ├── session.routes.ts   ★
│           └── session.swagger.ts  ★
└── server.ts

Pregunta que responde: ¿cómo queda el proyecto después de este ISS?

Anatomía del código

Archivo: src/features/auth/session/dto/…

Propósito

Definir los contratos de entrada y salida del feature. Son interfaces puras (interface), sin lógica.

Explicación

  • LoginDto — identifier (usuario o correo, normalizado a minúsculas) y password. Es la entrada de POST /api/sesion/login.
  • RefreshSessionDto — refresh_token. El token viaja en el cuerpo, no en Authorization, porque es una credencial de sesión, no un token de acceso.
  • LogoutSessionDto — también refresh_token: se revoca la sesión concreta que se presenta.
  • SessionTokensDto — el par de tokens: access_token, token_type ("Bearer"), expires_in, refresh_token y refresh_expires_in. El refresh se devuelve en claro solo aquí, porque el servidor guarda únicamente su hash.
  • ProfileDto — datos públicos del perfil: id, username, email, avatar, status. Nunca incluye password.
  • index.ts — barril que reexporta los cuatro DTOs.

Se conecta con

  • Entrada: los importa session.service.ts, session.controller.ts y session.swagger.ts.
  • Salida: no llama a nada; son tipos.

Archivo: src/features/auth/session/session.service.ts

Propósito

Ser el ciclo de vida de la sesión. Orquesta piezas ya construidas y no duplica su lógica.

Método Reutiliza Hace
login UsersRepository, comparePassword, RefreshTokensService.issue autentica y abre sesión
refresh RefreshTokensService.rotate renueva el access token rotando el refresh
logout RefreshTokensService.revokeByToken revoca la sesión
profile UsersRepository.findById devuelve la identidad
myPermissions ResourceRolesService.findEffectiveForUser lista los (method, path) vigentes

Explicación

  • login(body, deviceInfo) — exige identifier y password; busca con findByIdentifierWithPassword; si no existe o está inactive, lanza 401 "Invalid credentials"; si la contraseña no coincide (comparePassword), lanza el mismo 401; si todo va bien, issue(user.id, deviceInfo) abre la familia y buildTokens arma el par.
  • refresh(body, deviceInfo) — exige refresh_token; llama a rotate y traduce el resultado: invalid → 401 "Invalid refresh token", expired → 401 "Refresh token expired", reuse → 401 con el mensaje de familia revocada. Después revalida la identidad: si el usuario fue desactivado, revokeAllMine y 401 "User is not active".
  • logout(body) — exige refresh_token y llama a revokeByToken. Idempotente.
  • profile(userId) — busca el usuario; si no existe o no está activo, 404 "User not found"; si existe, lo proyecta con toProfile.
  • myPermissions(userId) — delega en resourceRolesService.findEffectiveForUser(userId).
  • buildTokens(user, refreshToken, refreshExpiresAt) — firma el access con signAccessToken({ id, username }) y calcula refresh_expires_in en segundos con Math.max(0, Math.floor(...)).
  • Seguridad del login — la respuesta es idéntica para «usuario inexistente» y «contraseña incorrecta»; evita la enumeración de usuarios.

Se conecta con

  • Entrada: SessionController (todas las operaciones).
  • Salida: UsersRepository, RefreshTokensService, ResourceRolesService, comparePassword, signAccessToken.

Archivo: src/features/auth/session/session.controller.ts

Propósito

Traducir HTTP ↔ service: leer el cuerpo y la identidad, y responder. Mezcla las dos modalidades base.

Explicación

  • extends BaseController — aporta la envoltura this.run(res, async () => { … }), que concentra el manejo de errores (y por eso los AppError del service salen como códigos HTTP).
  • OPEN — login, refresh y logout leen req.body y llaman al service; login y refresh responden 200 con el par de tokens, logout con { message: "Session closed" }.
  • JWT — profile y myPermissions obtienen la identidad con requireAuthUser(req).id, resuelta antes por el middleware authenticate.
  • deviceInfo(req) — helper local que toma el User-Agent, devuelve null si falta y lo recorta a 500 caracteres para la auditoría.

Se conecta con

  • Entrada: las rutas (session.routes.ts).
  • Salida: SessionService y, en las JWT, requireAuthUser.

Archivo: src/features/auth/session/session.routes.ts

Propósito

Montar las tres modalidades en un solo archivo, declarando qué middleware protege cada ruta.

Explicación

  • Clase SessionRoutes — expone sessionController y un método routes(app).
  • OPEN — POST /api/sesion/login, POST /api/sesion/refresh y POST /api/sesion/logout se registran sin middleware.
  • JWT — GET /api/sesion/perfil y GET /api/permisos se registran con authenticate (importado de ../access) por delante del controller.
  • Sin authorize — ninguna ruta de sesión lleva autorización granular: son puntos de acceso previos o ajenos a la matriz de permisos.

Se conecta con

  • Entrada: la instanciará el enrutador de la App (registro que no aparece en este ISS).
  • Salida: SessionController y el middleware authenticate.

Archivo: src/features/auth/session/session.swagger.ts

Propósito

Documentar el feature con las tres modalidades juntas y declarar la seguridad por operación.

Explicación

  • openSecurity — en login, refresh y logout, que no exigen access token.
  • bearerSecurity — en perfil y permisos, más la respuesta 401 normalizada.
  • Cuerpos documentados — requestBody con los esquemas Login y RefreshToken.
  • components.schemas — Login, RefreshToken y SessionTokens.
  • Nota didáctica del ISS — OPEN no significa «sin base de datos»: el login lee el hash de users y escribe refresh_tokens.

Se conecta con

  • Entrada: lo agrega el registry src/swagger/index.ts (patrón de ISS-05).
  • Salida: importa bearerSecurity, openSecurity y unauthorizedResponse.

Archivo: src/features/auth/session/http/…

Propósito

Dejar pruebas HTTP reproducibles (REST Client) de login, refresh y perfil.

Explicación

  • session.login.http — login con admin, login por correo, un 401 de credenciales inválidas, y variables encadenadas (@adminAccessToken, @adminRefreshToken, @expiresIn) para reutilizar en otros archivos.
  • session.refresh.http — login → refresh → usar el access nuevo → reusar el token viejo (401) → comprobar que el rotado tampoco sirve.
  • session.profile.http — perfil y permisos con token de seller, 401 sin token y logout idempotente.
  • Credenciales de ejemplo — admin / Admin123! (rol ADMIN, 58 permisos) y seller / Seller123! (rol SELLER, 7 permisos).

Se conecta con

  • Entrada: las ejecuta el cliente REST del editor.
  • Salida: consume los endpoints reales del servidor.

Flujos

Flujo de login (OPEN)

sequenceDiagram
    autonumber
    participant Cliente
    participant Ctrl as "SessionController"
    participant Svc as "SessionService"
    participant Users as "UsersRepository"
    participant RT as "RefreshTokensService"
    participant DB as "BD"

    Cliente->>Ctrl: POST /api/sesion/login
    Ctrl->>Svc: login(body, deviceInfo)
    Svc->>Users: findByIdentifierWithPassword(identifier)
    Users->>DB: SELECT usuario
    DB-->>Users: usuario o null
    Svc->>Svc: comparePassword()
    Svc->>RT: issue(user.id, deviceInfo)
    RT->>DB: INSERT refresh_tokens
    Svc->>Svc: signAccessToken()
    Svc-->>Ctrl: SessionTokensDto
    Ctrl-->>Cliente: 200 access_token + refresh_token

Pregunta que responde: ¿qué ocurre paso a paso cuando un usuario hace login?

Flujo de rotación del refresh token

sequenceDiagram
    autonumber
    participant Cliente
    participant Ctrl as "SessionController"
    participant Svc as "SessionService"
    participant RT as "RefreshTokensService"
    participant DB as "BD"

    Cliente->>Ctrl: POST /api/sesion/refresh
    Ctrl->>Svc: refresh(body, deviceInfo)
    Svc->>RT: rotate(refresh_token, deviceInfo)
    RT->>DB: marca el token presentado como used
    RT->>DB: INSERT nuevo refresh con la misma familia
    RT-->>Svc: kind ok con rawToken
    Svc->>Svc: revalida usuario y signAccessToken()
    Svc-->>Ctrl: par nuevo
    Ctrl-->>Cliente: 200 access + refresh rotado
    Note over Cliente,DB: Reuso: token viejo -> 401 y familia revocada

Pregunta que responde: ¿cómo se renueva la sesión y qué pasa si un refresh se reutiliza?

Ciclo de vida de la sesión

stateDiagram-v2
    [*] --> Activa: login valido
    Activa --> Activa: refresh con rotacion
    Activa --> Revocada: logout
    Activa --> Revocada: reuso detectado
    Activa --> Expirada: vence la ventana
    Revocada --> [*]
    Expirada --> [*]

Pregunta que responde: ¿por qué estados puede pasar una sesión desde que nace hasta que muere?

Las tres modalidades conviviendo

flowchart TD
    A["Cliente"] --> B{"¿Qué operación?"}
    B -->|"login / refresh / logout"| C["OPEN: credencial en el cuerpo"]
    B -->|"perfil / permisos"| D["JWT: authenticate"]
    B -->|"rutas de negocio"| E["JWT + RBAC: authenticate + authorize"]
    C --> F["200 con tokens o 401"]
    D --> G["200 con datos o 401"]
    E --> H["200/201 si autorizado o 403"]

Pregunta que responde: ¿cómo distingue el backend qué modalidad aplica a cada petición?

Comandos explicados

npm run db:seed && npm run dev

COMANDO
   ↓
npm run db:seed && npm run dev
   ↓
QUÉ HACE
   Siembra la base (usuarios, roles y permisos) y luego arranca el servidor.
   ↓
POR QUÉ SE NECESITA
   El recorrido E2E necesita las credenciales admin/seller y la matriz RBAC
   cargadas antes de probar login y permisos.
   ↓
QUÉ CREA O MODIFICA
   Filas en la BD (seed) y el proceso del servidor en http://localhost:4000.
   ↓
RESULTADO ESPERADO
   Seeders en verde y servidor arrancado sin error.
   ↓
CÓMO VERIFICARLO
   La consola muestra el arranque del servidor; detenerlo con Ctrl+C al final.

npx tsc --noEmit

COMANDO
   ↓
npx tsc --noEmit
   ↓
QUÉ HACE
   Comprueba los tipos de todo el proyecto sin generar archivos.
   ↓
POR QUÉ SE NECESITA
   Es un criterio de aceptación literal del ISS: el feature debe compilar.
   ↓
QUÉ CREA O MODIFICA
   Nada (no emite salida a disco).
   ↓
RESULTADO ESPERADO
   Sin errores.
   ↓
CÓMO VERIFICARLO
   Código de salida 0 y ninguna línea de error.

curl -s -X POST $BASE/api/sesion/login …

COMANDO
   ↓
curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
  -d '{"identifier":"admin","password":"Admin123!"}'
   ↓
QUÉ HACE
   Prueba la modalidad OPEN: envía credenciales y recibe el par de tokens.
   ↓
POR QUÉ SE NECESITA
   Es el punto de entrada del recorrido E2E.
   ↓
QUÉ CREA O MODIFICA
   Inserta una fila nueva en refresh_tokens (nueva familia).
   ↓
RESULTADO ESPERADO
   200 con access_token, token_type, expires_in, refresh_token y refresh_expires_in.
   ↓
CÓMO VERIFICARLO
   Guardar el access_token y usarlo en las comprobaciones JWT.

curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil

COMANDO
   ↓
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil
   ↓
QUÉ HACE
   Prueba la modalidad JWT contra el perfil propio.
   ↓
POR QUÉ SE NECESITA
   Demuestra que `authenticate` valida el token y revalida al usuario en la BD.
   ↓
QUÉ CREA O MODIFICA
   Nada (solo lectura).
   ↓
RESULTADO ESPERADO
   200 con { user } sin password; 401 si falta el token.
   ↓
CÓMO VERIFICARLO
   Repetir sin cabecera Authorization: debe responder 401.

curl -i -X POST -H "Authorization: Bearer $SELLER" … $BASE/api/clientes

COMANDO
   ↓
curl -i -X POST -H "Authorization: Bearer $SELLER" -H "Content-Type: application/json" \
  -d '{"name":"x","phone":"1","email":"x@x.com","password":"x"}' $BASE/api/clientes   # 403
   ↓
QUÉ HACE
   Prueba la modalidad JWT + RBAC con un token válido pero sin concesión.
   ↓
POR QUÉ SE NECESITA
   Demuestra que autenticación no es autorización: seller está autenticado
   pero no puede crear clientes.
   ↓
QUÉ CREA O MODIFICA
   Nada (la petición se rechaza).
   ↓
RESULTADO ESPERADO
   403.
   ↓
CÓMO VERIFICARLO
   Ver el código HTTP con `-i`; comparar con el mismo POST usando el token de admin.

curl -s -X POST $BASE/api/sesion/refresh …

COMANDO
   ↓
curl -s -X POST $BASE/api/sesion/refresh -H "Content-Type: application/json" \
  -d '{"refresh_token":"<refresh_token del login>"}'
   ↓
QUÉ HACE
   Renueva el access token rotando el refresh.
   ↓
POR QUÉ SE NECESITA
   Prueba la renovación automática sin volver a pedir credenciales.
   ↓
QUÉ CREA O MODIFICA
   Marca el refresh anterior como usado e inserta uno nuevo de la misma familia.
   ↓
RESULTADO ESPERADO
   200 con un par nuevo; el refresh anterior queda inválido.
   ↓
CÓMO VERIFICARLO
   Reenviar el token viejo: debe responder 401 y revocar la familia.

GET /api/permisos

COMANDO
   ↓
GET /api/permisos
   ↓
QUÉ HACE
   Ejecuta la misma consulta RBAC que el middleware `authorize` y devuelve
   los permisos efectivos del usuario autenticado.
   ↓
POR QUÉ SE NECESITA
   Es la herramienta de depuración del RBAC y parte del E2E.
   ↓
QUÉ CREA O MODIFICA
   Nada (solo lectura).
   ↓
RESULTADO ESPERADO
   58 recursos para `admin` y 7 para `seller`.
   ↓
CÓMO VERIFICARLO
   Llamarlo con cada token y contar los `(method, path)`.

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: su objetivo, sus criterios y sus bloques de código. Se reproduce sin resumir y sin reformatear; solo se han degradado los encabezados un nivel para que aniden bajo esta sección, y se han reescrito los enlaces relativos hacia ../manual/.

Fase II: Auth con RBAC — ISS-15 — Feature Session (login, refresh, logout, perfil y permisos)

Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto: app-storelab-express-ii — Express 5 + TypeScript + Sequelize, arquitectura por features. - Ya construido: Fase I, ISS-09 base, ISS-10 Users, ISS-11 Roles/Resources, ISS-12 pivotes, ISS-13 middlewares e ISS-14 RefreshTokens. - Recorrido obligatorio de una petición: HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (Fase II): ../bd-storelab.md §17 (ciclo de vida de la sesión) y §18 (tres formas de acceder a la BD). - Capas, convenciones y reglas transversales: 00-contexto.md.

Este ISS
Título Feature Session (login, refresh, logout, perfil y permisos)
Feature / tabla features/auth/session/ · cruza users y refresh_tokens
API /api/sesion/* y /api/permisos (OPEN y JWT)
Depende de ISS-14 — Feature RefreshTokens
Habilita Cierre de Fase II

Contenido de este ISS

  • 20.1 DTOs del feature
  • 20.2 Service (login, refresh, logout, perfil, permisos)
  • 20.3 Controller
  • 20.4 Rutas: las tres modalidades conviviendo en un archivo
  • 20.5 Swagger
  • 20.6 Pruebas HTTP
  • 20.7 Verificación end-to-end de las tres modalidades

Objetivo: exponer el punto de entrada al sistema (login), la renovación automática (refresh con rotación), la salida (logout), el perfil del usuario y sus permisos efectivos. Es el feature que demuestra que las tres modalidades conviven sin fricción.

Bloqueado por: ISS-14.

Criterios de aceptación (ISS-15) — consolidados

  • [ ] 20.1 session/dto/ (login.dto.ts, refresh-session.dto.ts, logout-session.dto.ts, session-response.dto.ts, index.ts)
  • [ ] 20.2 session.service.ts: login (verifica contraseña + emite sesión), refresh (rota), logout (revoca), profile, myPermissions
  • [ ] 20.3 session.controller.ts con this.run(res, …)
  • [ ] 20.4 session.routes.ts: login/refresh/logout OPEN; perfil/permisos JWT
  • [ ] 20.5 session.swagger.ts con security: [] en las OPEN y bearerAuth en las JWT
  • [ ] 20.6 archivos .http de login, refresh y perfil
  • [ ] 20.7 recorrido E2E que prueba OPEN → JWT → JWT + RBAC
  • [ ] npx tsc --noEmit OK

20.1 DTOs del feature

: > src/features/auth/session/dto/login.dto.ts
cat >> src/features/auth/session/dto/login.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/sesion/login` (modalidad OPEN).
 *
 * `identifier` acepta **usuario o correo**: la consulta de credenciales busca por
 * cualquiera de los dos, normalizando a minúsculas.
 */
export interface LoginDto {
  identifier: string;
  password: string;
}
EOF
: > src/features/auth/session/dto/refresh-session.dto.ts
cat >> src/features/auth/session/dto/refresh-session.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/sesion/refresh` (modalidad OPEN con credencial
 * de sesión).
 *
 * El refresh token viaja en el **cuerpo**, no en la cabecera `Authorization`:
 * es una credencial de sesión, no un token de acceso.
 */
export interface RefreshSessionDto {
  refresh_token: string;
}
EOF
: > src/features/auth/session/dto/logout-session.dto.ts
cat >> src/features/auth/session/dto/logout-session.dto.ts << 'EOF'
/**
 * Datos de entrada de `POST /api/sesion/logout`.
 *
 * Se envía el refresh token que se quiere revocar (la sesión concreta). Es
 * idempotente: repetirlo no devuelve error.
 */
export interface LogoutSessionDto {
  refresh_token: string;
}
EOF
: > src/features/auth/session/dto/session-response.dto.ts
cat >> src/features/auth/session/dto/session-response.dto.ts << 'EOF'
/**
 * Respuesta de `login` y `refresh`: el **par de tokens**.
 *
 * - `access_token`: JWT corto, autocontenido; viaja en `Authorization: Bearer`.
 * - `refresh_token`: token opaco larga vida; **se devuelve solo aquí**, en claro,
 *   porque el servidor guarda únicamente su hash. El cliente debe guardarlo y
 *   enviarlo a `/api/sesion/refresh` para renovar sin volver a autenticarse.
 * - `expires_in`: segundos de vida del token de acceso (para que el cliente
 *   programe la renovación *antes* de que expire).
 */
export interface SessionTokensDto {
  access_token: string;
  token_type: "Bearer";
  expires_in: number;
  refresh_token: string;
  refresh_expires_in: number;
}

/** Datos públicos del perfil propio (modalidad JWT). Nunca incluye `password`. */
export interface ProfileDto {
  id: number;
  username: string;
  email: string;
  avatar: string | null;
  status: "active" | "inactive";
}
EOF
: > src/features/auth/session/dto/index.ts
cat >> src/features/auth/session/dto/index.ts << 'EOF'
export * from "./login.dto";
export * from "./refresh-session.dto";
export * from "./logout-session.dto";
export * from "./session-response.dto";
EOF

20.2 Service

SessionService orquesta piezas ya construidas y no duplica su lógica:

Método Reutiliza Hace
login UsersRepository.findByUsernameOrEmail, verifyPassword, RefreshTokensService.issue autentica y abre sesión
refresh RefreshTokensService.rotate renueva el access token rotando el refresh
logout RefreshTokensService.revoke revoca la sesión
profile requireAuthUser devuelve la identidad
myPermissions UsersService.getEffectivePermissions lista los (method, path) vigentes

Seguridad del login: si el usuario no existe o la contraseña no coincide, la respuesta es el mismo 401 genérico. No se distingue «usuario inexistente» de «contraseña incorrecta» (evita enumeración de usuarios). Un usuario inactive tampoco puede iniciar sesión.

: > src/features/auth/session/session.service.ts
cat >> src/features/auth/session/session.service.ts << 'EOF'
import {
  LoginDto,
  LogoutSessionDto,
  ProfileDto,
  RefreshSessionDto,
  SessionTokensDto,
} from "./dto";
import { UsersRepository } from "../users/users.repository";
import { RefreshTokensService } from "../refresh-tokens/refresh-tokens.service";
import { ResourceRolesService } from "../resource-roles/resource-roles.service";
import { EffectivePermissionDto } from "../resource-roles/dto";
import { User } from "../users/user.model";
import { AppError } from "../../../shared/errors/app-error";
import { comparePassword } from "../../../shared/auth/password";
import { ACCESS_TOKEN_TTL_SECONDS, signAccessToken } from "../../../shared/auth/jwt";

/**
 * Capa Service del feature Session — **el ciclo de vida de la sesión**.
 *
 * Cubre las dos modalidades sin autorización granular:
 *
 * | Operación | Modalidad | Escribe seguridad |
 * |---|---|---|
 * | `login`    | OPEN (valida credenciales) | `refresh_tokens` (nueva familia) |
 * | `refresh`  | OPEN (credencial de sesión) | `refresh_tokens` (rotación) |
 * | `logout`   | OPEN (credencial de sesión) | `refresh_tokens` (revocación) |
 * | `profile`  | JWT | — (solo lectura) |
 *
 * **Renovación automática.** El cliente mantiene la sesión sin volver a pedir
 * credenciales: cuando el access token está por expirar, llama a `refresh` con
 * el refresh token y recibe un par nuevo. La ventana del refresh token se
 * reinicia en cada rotación (ventana deslizante), así que un usuario que sigue
 * trabajando no se ve expulsado; uno inactivo durante toda la ventana, sí.
 */
export class SessionService {
  public constructor(
    private readonly usersRepository: UsersRepository = new UsersRepository(),
    private readonly refreshTokensService: RefreshTokensService = new RefreshTokensService(),
    private readonly resourceRolesService: ResourceRolesService = new ResourceRolesService()
  ) {}

  // ================== LOGIN (OPEN) ==================
  /**
   * Valida credenciales y abre una sesión.
   *
   * Nota de seguridad: la respuesta es **la misma** para "usuario inexistente" y
   * "contraseña incorrecta" (`Invalid credentials`) para no revelar qué usuarios
   * existen. La comparación de la contraseña ocurre en memoria; el hash nunca
   * sale de la base de datos.
   */
  public async login(body: LoginDto, deviceInfo: string | null): Promise<SessionTokensDto> {
    if (!body.identifier || !body.password) {
      throw new AppError(400, "identifier and password are required");
    }

    const user = await this.usersRepository.findByIdentifierWithPassword(body.identifier);
    if (!user || user.status !== "active") {
      throw new AppError(401, "Invalid credentials");
    }

    const matches = await comparePassword(body.password, user.password);
    if (!matches) {
      throw new AppError(401, "Invalid credentials");
    }

    const session = await this.refreshTokensService.issue(user.id, deviceInfo);
    return this.buildTokens(user, session.rawToken, session.expiresAt);
  }

  // ================== REFRESH (OPEN con credencial de sesión) ==================
  /**
   * Rota el refresh token y emite un par nuevo.
   *
   * Traduce el resultado de la rotación (que no lanza dentro de la transacción,
   * para no deshacer la revocación por reutilización) al error HTTP que
   * corresponde:
   *  - `invalid`  -> 401
   *  - `expired`  -> 401
   *  - `reuse`    -> 401 **habiendo revocado toda la familia**
   */
  public async refresh(
    body: RefreshSessionDto,
    deviceInfo: string | null
  ): Promise<SessionTokensDto> {
    if (!body.refresh_token) {
      throw new AppError(400, "refresh_token is required");
    }

    const outcome = await this.refreshTokensService.rotate(body.refresh_token, deviceInfo);

    if (outcome.kind === "invalid") {
      throw new AppError(401, "Invalid refresh token");
    }
    if (outcome.kind === "expired") {
      throw new AppError(401, "Refresh token expired");
    }
    if (outcome.kind === "reuse") {
      throw new AppError(401, "Refresh token reuse detected: session family revoked");
    }

    // Revalida la identidad: si el usuario fue desactivado, se corta la sesión
    // aunque el refresh token siga siendo válido.
    const user = await this.usersRepository.findById(outcome.userId);
    if (!user || user.status !== "active") {
      await this.refreshTokensService.revokeAllMine(outcome.userId);
      throw new AppError(401, "User is not active");
    }

    return this.buildTokens(user, outcome.rawToken, outcome.expiresAt);
  }

  // ================== LOGOUT (OPEN con credencial de sesión) ==================
  /** Revoca la sesión del refresh token presentado. Idempotente. */
  public async logout(body: LogoutSessionDto): Promise<void> {
    if (!body.refresh_token) {
      throw new AppError(400, "refresh_token is required");
    }
    await this.refreshTokensService.revokeByToken(body.refresh_token);
  }

  // ================== PERFIL (JWT) ==================
  /** Datos públicos del usuario autenticado. */
  public async profile(userId: number): Promise<ProfileDto> {
    const user = await this.usersRepository.findById(userId);
    if (!user || user.status !== "active") {
      throw new AppError(404, "User not found");
    }
    return toProfile(user);
  }

  /** Permisos efectivos del propio usuario (modalidad JWT, sin RBAC). */
  public async myPermissions(userId: number): Promise<EffectivePermissionDto[]> {
    return this.resourceRolesService.findEffectiveForUser(userId);
  }

  // ================== HELPERS ==================
  /** Arma el par de tokens: firma el access y adjunta el refresh recién emitido. */
  private buildTokens(user: User, refreshToken: string, refreshExpiresAt: Date): SessionTokensDto {
    const access = signAccessToken({ id: user.id, username: user.username });
    return {
      access_token: access.token,
      token_type: "Bearer",
      expires_in: access.expiresIn,
      refresh_token: refreshToken,
      refresh_expires_in: Math.max(
        0,
        Math.floor((refreshExpiresAt.getTime() - Date.now()) / 1000)
      ),
    };
  }
}

/** Proyección a `ProfileDto`: solo campos públicos. */
function toProfile(user: User): ProfileDto {
  return {
    id: user.id,
    username: user.username,
    email: user.email,
    avatar: user.avatar ?? null,
    status: user.status,
  };
}

/** Reexporta la constante para que el controller pueda documentar `expires_in`. */
export { ACCESS_TOKEN_TTL_SECONDS };
EOF

Renovación automática mientras el usuario trabaja: el cliente llama a POST /api/sesion/refresh con el refresh token cuando el access token expira. Como el refresh rota en cada uso y la familia se revoca ante reuso, la sesión puede prolongarse indefinidamente sin degradar la seguridad.

20.3 Controller

: > src/features/auth/session/session.controller.ts
cat >> src/features/auth/session/session.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { requireAuthUser } from "../../../shared/auth/auth-user";
import { LoginDto, LogoutSessionDto, RefreshSessionDto } from "./dto";
import { SessionService } from "./session.service";

/**
 * Capa Controller del feature Session.
 *
 * Mezcla las dos modalidades base:
 *  - `login`, `refresh` y `logout` son **OPEN** (no hay identidad previa; la
 *    credencial va en el cuerpo);
 *  - `profile` y `myPermissions` son **JWT** (la identidad la resolvió
 *    `authenticate` antes de llegar aquí).
 */
export class SessionController extends BaseController {
  public constructor(
    private readonly service: SessionService = new SessionService()
  ) {
    super();
  }

  // ================== OPEN ==================
  /** Inicia sesión: credenciales -> par de tokens. */
  public async login(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const tokens = await this.service.login(req.body as LoginDto, deviceInfo(req));
      res.status(200).json(tokens);
    });
  }

  /** Renueva el access token rotando el refresh token. */
  public async refresh(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const tokens = await this.service.refresh(req.body as RefreshSessionDto, deviceInfo(req));
      res.status(200).json(tokens);
    });
  }

  /** Cierra la sesión del refresh token presentado. */
  public async logout(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      await this.service.logout(req.body as LogoutSessionDto);
      res.status(200).json({ message: "Session closed" });
    });
  }

  // ================== JWT ==================
  /** Perfil del usuario autenticado. */
  public async profile(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const user = await this.service.profile(requireAuthUser(req).id);
      res.status(200).json({ user });
    });
  }

  /** Permisos efectivos del usuario autenticado. */
  public async myPermissions(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const permissions = await this.service.myPermissions(requireAuthUser(req).id);
      res.status(200).json({ permissions });
    });
  }
}

/** `device_info` de auditoría a partir del encabezado `User-Agent`. */
function deviceInfo(req: Request): string | null {
  const value = req.headers["user-agent"];
  if (!value) return null;
  return String(value).slice(0, 500);
}
EOF

20.4 Rutas — las tres modalidades en un solo archivo

Ruta Modalidad Middleware
POST /api/sesion/login OPEN —
POST /api/sesion/refresh OPEN (credencial de sesión) —
POST /api/sesion/logout OPEN (credencial de sesión) —
GET /api/sesion/perfil JWT authenticate
GET /api/permisos JWT authenticate

Ninguna lleva authorize: la autorización granular no aplica a los puntos de acceso previos a la matriz. GET /api/permisos devuelve, eso sí, los permisos efectivos del usuario autenticado —la misma consulta que usa el middleware authorize—, lo que lo hace ideal para depurar el RBAC.

: > src/features/auth/session/session.routes.ts
cat >> src/features/auth/session/session.routes.ts << 'EOF'
import { Application } from "express";
import { SessionController } from "./session.controller";
import { authenticate } from "../access";

/**
 * Rutas del feature Session — **las tres modalidades en un solo archivo**.
 *
 * | Ruta | Modalidad | Middleware |
 * |---|---|---|
 * | `POST /api/sesion/login`   | OPEN | — |
 * | `POST /api/sesion/refresh` | OPEN (credencial de sesión) | — |
 * | `POST /api/sesion/logout`  | OPEN (credencial de sesión) | — |
 * | `GET  /api/sesion/perfil`  | JWT | `authenticate` |
 * | `GET  /api/permisos`       | JWT | `authenticate` |
 *
 * Ninguna lleva `authorize`: la autorización granular no aplica a los puntos de
 * acceso previos o ajenos a la matriz de permisos. `/api/permisos` devuelve, eso
 * sí, **los permisos efectivos** del usuario autenticado (la misma consulta que
 * usa el middleware `authorize`), lo que lo hace ideal para depurar el RBAC.
 */
export class SessionRoutes {
  public sessionController: SessionController = new SessionController();

  public routes(app: Application): void {
    // login (OPEN)
    app
      .route("/api/sesion/login")
      .post(this.sessionController.login.bind(this.sessionController));

    // refresh (OPEN + refresh token)
    app
      .route("/api/sesion/refresh")
      .post(this.sessionController.refresh.bind(this.sessionController));

    // logout (OPEN + refresh token)
    app
      .route("/api/sesion/logout")
      .post(this.sessionController.logout.bind(this.sessionController));

    // perfil (JWT)
    app
      .route("/api/sesion/perfil")
      .get(authenticate, this.sessionController.profile.bind(this.sessionController));

    // permisos efectivos del usuario autenticado (JWT)
    app
      .route("/api/permisos")
      .get(authenticate, this.sessionController.myPermissions.bind(this.sessionController));
  }
}
EOF

20.5 Swagger

refresh y logout son OPEN de facto: no exigen access token, sino el refresh token en el cuerpo. Por eso declaran security: [] y documentan su propio cuerpo.

: > src/features/auth/session/session.swagger.ts
cat >> src/features/auth/session/session.swagger.ts << 'EOF'
import {
  bearerSecurity,
  openSecurity,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature Session — **las tres modalidades juntas**.
 *
 * - `login` / `refresh` / `logout`: **OPEN**. Nota didáctica: OPEN no significa
 *   "sin base de datos", significa "sin identidad previa". El login **lee** el
 *   hash de `users` y **escribe** `refresh_tokens`; el refresh **rota** el token.
 * - `perfil` / `permisos`: **JWT**.
 *
 * La respuesta de `login` y `refresh` es el **par de tokens**. El `refresh_token`
 * se devuelve en claro **solo aquí**: el servidor guarda únicamente su SHA-256.
 */
export const sessionSwagger = {
  tags: [
    { name: "Sesión", description: "Login, renovación, cierre y perfil — **OPEN** + **JWT**" },
  ],
  paths: {
    "/api/sesion/login": {
      post: {
        tags: ["Sesión"],
        summary: "Iniciar sesión (OPEN)",
        description:
          "Modalidad **OPEN**. Valida usuario/correo + contraseña y abre una sesión: " +
          "emite un access token corto (JWT) y un refresh token persistido como hash, con un `family_id` nuevo. " +
          "La respuesta es idéntica para usuario inexistente y contraseña incorrecta (no se enumeran usuarios).",
        security: openSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/Login" } },
          },
        },
        responses: {
          "200": { description: "Par de tokens (`access_token`, `refresh_token`, `expires_in`)" },
          "400": { description: "Faltan `identifier` o `password`" },
          "401": { description: "Credenciales inválidas o usuario inactivo" },
        },
      },
    },
    "/api/sesion/refresh": {
      post: {
        tags: ["Sesión"],
        summary: "Renovar el access token (OPEN con credencial de sesión)",
        description:
          "Modalidad **OPEN**. **Rota** el refresh token: invalida el presentado y emite uno nuevo con el mismo " +
          "`family_id`. Si se presenta un token ya rotado, se interpreta como **reutilización** y se revoca toda la " +
          "familia (401). La rotación es atómica y con bloqueo de fila, así que dos peticiones simultáneas no emiten " +
          "dos tokens válidos. La ventana del refresh se reinicia en cada rotación: renovación automática mientras el usuario trabaja.",
        security: openSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/RefreshToken" } },
          },
        },
        responses: {
          "200": { description: "Par de tokens nuevo (`access_token` + `refresh_token` rotado)" },
          "400": { description: "Falta `refresh_token`" },
          "401": {
            description:
              "Token inválido, expirado o **reutilizado** (en este último caso, la familia queda revocada)",
          },
        },
      },
    },
    "/api/sesion/logout": {
      post: {
        tags: ["Sesión"],
        summary: "Cerrar sesión (OPEN con credencial de sesión)",
        description:
          "Modalidad **OPEN**. Revoca el refresh token presentado. Idempotente: repetirlo no devuelve error. " +
          "El access token sigue siendo válido hasta expirar (vida corta); para invalidación inmediata, desactivar el usuario.",
        security: openSecurity,
        requestBody: {
          required: true,
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/RefreshToken" } },
          },
        },
        responses: {
          "200": { description: "Sesión cerrada (`{ message }`)" },
          "400": { description: "Falta `refresh_token`" },
        },
      },
    },
    "/api/sesion/perfil": {
      get: {
        tags: ["Sesión"],
        summary: "Perfil del usuario autenticado (JWT)",
        description:
          "Modalidad **JWT**. El middleware `authenticate` valida el token y **revalida en la base** que el usuario " +
          "sigue activo: desactivar una cuenta invalida sus tokens al instante (401).",
        security: bearerSecurity,
        responses: {
          "200": { description: "Perfil (`{ user }`) — nunca incluye `password`" },
          "401": unauthorizedResponse,
        },
      },
    },
    "/api/permisos": {
      get: {
        tags: ["Sesión"],
        summary: "Mis permisos efectivos (JWT)",
        description:
          "Modalidad **JWT**. Ejecuta la misma consulta que el middleware `authorize` " +
          "(`resource_roles → roles → role_users → resources`, todos los eslabones activos) y devuelve el par " +
          "`(method, path)` de cada permiso. Es la herramienta para **depurar el RBAC**: lo que aparece aquí es exactamente lo que autoriza.",
        security: bearerSecurity,
        responses: {
          "200": { description: "Permisos efectivos (`{ permissions: [...] }`)" },
          "401": unauthorizedResponse,
        },
      },
    },
  },
  components: {
    schemas: {
      Login: {
        type: "object",
        required: ["identifier", "password"],
        properties: {
          identifier: { type: "string", example: "admin", description: "`username` o `email`" },
          password: { type: "string", format: "password", example: "Admin123!" },
        },
      },
      RefreshToken: {
        type: "object",
        required: ["refresh_token"],
        properties: {
          refresh_token: { type: "string", example: "9f2c... (opaco, no es un JWT)" },
        },
      },
      SessionTokens: {
        type: "object",
        properties: {
          access_token: { type: "string", description: "JWT firmado (HS256), vida corta" },
          token_type: { type: "string", example: "Bearer" },
          expires_in: { type: "integer", example: 900, description: "Segundos de vida del access token" },
          refresh_token: { type: "string", description: "Token opaco; se devuelve solo en login/refresh" },
          refresh_expires_in: { type: "integer", example: 604800 },
        },
      },
    },
  },
};
EOF

20.6 Pruebas HTTP

: > src/features/auth/session/http/session.login.http
cat >> src/features/auth/session/http/session.login.http << 'EOF'
### Feature Session — LOGIN (modalidad OPEN)
### OPEN = sin identidad previa. Aquí SÍ se consulta `users` (hash) y se escribe `refresh_tokens`.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

### Credenciales de ejemplo
# admin  / Admin123!   -> rol ADMIN  (58 permisos)
# seller / Seller123!  -> rol SELLER (7 permisos)

### Login por correo (mismo endpoint, `identifier` acepta username o email)
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin@storelab.local",
  "password": "Admin123!"
}

### Respuesta 401 - credenciales inválidas (mismo mensaje que "usuario inexistente")
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "password-incorrecta"
}

### Guarda los tokens para los demás archivos .http
@adminAccessToken = {{loginAdmin.response.body.$.access_token}}
@adminRefreshToken = {{loginAdmin.response.body.$.refresh_token}}
@expiresIn = {{loginAdmin.response.body.$.expires_in}}
EOF
: > src/features/auth/session/http/session.refresh.http
cat >> src/features/auth/session/http/session.refresh.http << 'EOF'
### Feature Session — REFRESH (modalidad OPEN con credencial de sesión)
### Renovación automática: el cliente llama aquí cuando el access token está por expirar.
### ROTACIÓN: el refresh token enviado queda inválido y se emite uno nuevo (misma familia).
### REUSE DETECTION: si se reenvía un token ya rotado -> 401 y se revoca toda la familia.
@baseUrl = http://localhost:4000

### 1) Login para obtener el par inicial
# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

### 2) Refresh: devuelve access_token nuevo + refresh_token ROTADO
# @name refresh
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json

{
  "refresh_token": "{{loginAdmin.response.body.$.refresh_token}}"
}

### 3) El access token nuevo sirve en una ruta JWT
GET {{baseUrl}}/api/sesion/perfil
Authorization: Bearer {{refresh.response.body.$.access_token}}

### 4) REUSE: reenviar el token viejo -> 401 y la familia queda revocada
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json

{
  "refresh_token": "{{loginAdmin.response.body.$.refresh_token}}"
}

### 5) Consecuencia: el token rotado (paso 2) tampoco sirve ya -> 401
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json

{
  "refresh_token": "{{refresh.response.body.$.refresh_token}}"
}
EOF
: > src/features/auth/session/http/session.profile.http
cat >> src/features/auth/session/http/session.profile.http << 'EOF'
### Feature Session — LOGOUT y PERFIL (OPEN con credencial de sesión + JWT)
@baseUrl = http://localhost:4000

# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "seller",
  "password": "Seller123!"
}

### PERFIL — modalidad JWT: `authenticate` valida el token y REVALIDA en la BD
### que el usuario sigue activo (desactivarlo invalida sus tokens al instante).
GET {{baseUrl}}/api/sesion/perfil
Authorization: Bearer {{loginSeller.response.body.$.access_token}}

### MIS PERMISOS — modalidad JWT: misma consulta RBAC que usa `authorize`
### (seller -> 7 permisos; admin -> 58). Es la herramienta para depurar el RBAC.
GET {{baseUrl}}/api/permisos
Authorization: Bearer {{loginSeller.response.body.$.access_token}}

### Sin token -> 401 (no autenticado)
GET {{baseUrl}}/api/sesion/perfil

### LOGOUT — modalidad OPEN con credencial de sesión. Idempotente.
POST {{baseUrl}}/api/sesion/logout
Content-Type: application/json

{
  "refresh_token": "{{loginSeller.response.body.$.refresh_token}}"
}

### Ya no se puede renovar -> 401
POST {{baseUrl}}/api/sesion/refresh
Content-Type: application/json

{
  "refresh_token": "{{loginSeller.response.body.$.refresh_token}}"
}
EOF

20.7 Verificación end-to-end de las tres modalidades

npm run db:seed && npm run dev
BASE=http://localhost:4000

# 1) OPEN — login (no requiere identidad)
curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
  -d '{"identifier":"admin","password":"Admin123!"}'

# 2) JWT — perfil con el access token (sin permisos RBAC de por medio)
ADMIN=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
  -d '{"identifier":"admin","password":"Admin123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil

# 3) JWT + RBAC — el mismo token, ahora sí, autoriza negocio
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/clientes

# 4) JWT + RBAC (denegado) — seller no tiene POST /api/clientes
SELLER=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
  -d '{"identifier":"seller","password":"Seller123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -i -X POST -H "Authorization: Bearer $SELLER" -H "Content-Type: application/json" \
  -d '{"name":"x","phone":"1","email":"x@x.com","password":"x"}' $BASE/api/clientes   # 403

# 5) Renovación — rotar el refresh token
curl -s -X POST $BASE/api/sesion/refresh -H "Content-Type: application/json" \
  -d '{"refresh_token":"<refresh_token del login>"}'
Comprobación Esperado
POST /api/sesion/login con credenciales válidas 200 + access_token + refresh_token
POST /api/sesion/login con contraseña errónea 401 genérico
GET /api/sesion/perfil sin token 401
GET /api/permisos con token de seller lista de 7 recursos
POST /api/clientes con token de seller 403
POST /api/sesion/refresh con el refresh válido 200 + nuevos tokens; el anterior queda used
Reusar el refresh anterior 401 + familia revocada

DoD del ISS-15

  • [ ] Todos los criterios de aceptación (20.1 … 20.7) cumplidos
  • [ ] Las tres modalidades son observables con curl (401 sin token, 403 sin concesión, 200/201 con ella)
  • [ ] GET /api/permisos refleja la matriz real (58 para admin, 7 para seller)
  • [ ] La rotación de refresh invalida el token anterior
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Diagnóstico

Síntoma Causa probable Solución
401 al hacer login con credenciales correctas Usuario inactive o contraseña no coincide Revisar status y re-sembrar; el mensaje es deliberadamente genérico
Siempre 401 Invalid credentials identifier mal escrito (el login acepta usuario o correo) Probar con admin y con admin@storelab.local
401 en perfil con token válido El usuario fue desactivado (se revalida en la BD) Reactivarlo o volver a hacer login con un usuario activo
refresh responde 401 la segunda vez Rotación: el token presentado ya se usó Es el comportamiento correcto; usar el token rotado del paso anterior
reuse devuelve 401 y el token nuevo tampoco sirve Se detectó reutilización y se revocó toda la familia Volver a hacer login: la familia se corta por seguridad
400 refresh_token is required El cuerpo no lleva el refresh token Enviarlo en el cuerpo, no en Authorization
403 en /api/clientes con token de seller El rol no tiene la concesión POST /api/clientes Usar el token de admin o revisar GET /api/permisos
GET /api/permisos devuelve menos de lo esperado Eslabón inactivo en la matriz RBAC Revisar role_users/resource_roles (ISS-12)
npx tsc --noEmit falla Import o tipo del feature incompleto Revisar los imports de DTOs y de shared/auth
El servidor no reconoce /api/sesion/* SessionRoutes no está registrado en la App Es el enganche del Cierre de Fase II

Pregunta que responde: si algo falla en login, refresh o permisos, ¿por dónde empiezo a mirar?

Conexión con el resto del curso

  • Lo habilita: ISS-14 — Feature RefreshTokens. Sin la rotación y la detección de reúso, el refresh no podría delegar.
  • Reutiliza: la base de seguridad de ISS-09 (AppError, JWT, bcrypt), el feature users de ISS-10, la matriz RBAC de ISS-11 y ISS-12, y los middlewares de ISS-13.
  • Reutiliza un patrón: el módulo Swagger por feature que viste en ISS-05; sessionSwagger se agrega al registry como cualquier otro.
  • Lo usa: el Cierre de Fase II, que registra SessionRoutes en el enrutador de la App.
  • Idea de fondo: este feature es la prueba de que las tres modalidades se suman: eliminar una empobrecería el sistema.

Pregunta que responde: ¿con qué piezas anteriores y posteriores del curso se conecta este ISS?

Criterios de aceptación

Los del ISS, textuales:

  • [ ] 20.1 session/dto/ (login.dto.ts, refresh-session.dto.ts, logout-session.dto.ts, session-response.dto.ts, index.ts)
  • [ ] 20.2 session.service.ts: login (verifica contraseña + emite sesión), refresh (rota), logout (revoca), profile, myPermissions
  • [ ] 20.3 session.controller.ts con this.run(res, …)
  • [ ] 20.4 session.routes.ts: login/refresh/logout OPEN; perfil/permisos JWT
  • [ ] 20.5 session.swagger.ts con security: [] en las OPEN y bearerAuth en las JWT
  • [ ] 20.6 archivos .http de login, refresh y perfil
  • [ ] 20.7 recorrido E2E que prueba OPEN → JWT → JWT + RBAC
  • [ ] npx tsc --noEmit OK

DoD del ISS, textual:

  • [ ] Todos los criterios de aceptación (20.1 … 20.7) cumplidos
  • [ ] Las tres modalidades son observables con curl (401 sin token, 403 sin concesión, 200/201 con ella)
  • [ ] GET /api/permisos refleja la matriz real (58 para admin, 7 para seller)
  • [ ] La rotación de refresh invalida el token anterior
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Evaluación

Preguntas de comprensión

  1. ¿Por qué la respuesta es la misma para «usuario inexistente» y «contraseña incorrecta»? Para evitar la enumeración de usuarios. Si los mensajes difirieran, un atacante podría descubrir qué cuentas existen probando identificadores. El service lanza el mismo 401 "Invalid credentials" en ambos casos.

  2. ¿Qué significa que refresh «rota» el token y por qué mejora la seguridad? Significa que el refresh presentado se marca como usado y se emite uno nuevo de la misma familia. Así, el token robado solo sirve hasta que el legítimo se use; en cuanto uno de los dos aparezca «ya usado», el sistema detecta la reutilización y revoca la familia completa.

  3. ¿Por qué logout es idempotente y por qué eso es deseable? Porque revoca el refresh presentado sin fallar si ya estaba revocado. Es deseable porque el cliente puede reintentar el cierre de sesión (por ejemplo, tras un timeout) sin recibir un error que le impida completar la operación.

  4. /api/sesion/perfil usa solo authenticate, no authorize. ¿Por qué? Porque el perfil es información del propio usuario: basta con saber quién es (autenticación). La autorización granular responde «¿puede este usuario tocar este recurso?», pregunta que no aplica al perfil propio.

  5. ¿Qué devuelve GET /api/permisos y por qué decimos que es ideal para depurar el RBAC? Devuelve los permisos efectivos —los pares (method, path)— ejecutando la misma consulta que usa el middleware authorize. Si un endpoint que esperas autorizado no aparece aquí, el fallo está en la matriz (role_users/resource_roles), no en el middleware.

  6. ¿Por qué refresh revalida al usuario aunque el refresh token siga siendo válido? Porque un token válido no garantiza que la cuenta siga habilitada. Si el usuario fue desactivado, el service llama a revokeAllMine y corta la sesión con 401 "User is not active", de modo que desactivar una cuenta invalida su acceso sin esperar a que expire el token.

  7. El ISS dice que OPEN no significa «sin base de datos». ¿Por qué? Porque OPEN solo describe la identidad: no hay access token previo. Pero login lee el hash de users, refresh lee y escribe refresh_tokens y logout revoca. Es decir, las rutas OPEN acceden a la BD con normalidad; lo que no hacen es exigir una identidad ya autenticada.

  8. ¿Qué diferencia hay entre el 403 de seller en /api/clientes y el 401 que recibiría sin token? 401 es «no sé quién eres» (falta o falla la autenticación); 403 es «sé quién eres, pero no puedes». En el E2E, seller está autenticado, pero su rol no tiene la concesión para POST /api/clientes, así que recibe 403.

Ejercicios

Ejercicio 1 — Traza el login. Enumera, en orden, las llamadas que ejecuta SessionService.login desde que recibe el cuerpo hasta que devuelve el par de tokens, e indica en qué punto exacto se decide un 401.

Respuesta razonada 1. Valida que `identifier` y `password` existan (si no, `400`). 2. `usersRepository.findByIdentifierWithPassword(identifier)`. 3. Si no hay usuario o `status !== "active"` → `401 "Invalid credentials"` (**primer punto de decisión**). 4. `comparePassword(body.password, user.password)`. 5. Si no coincide → `401 "Invalid credentials"` (**segundo punto, mismo mensaje**). 6. `refreshTokensService.issue(user.id, deviceInfo)` (crea la familia en `refresh_tokens`). 7. `buildTokens` firma el access con `signAccessToken` y calcula `refresh_expires_in`. 8. Devuelve `SessionTokensDto`.

Ejercicio 2 — Detecta el reuso. Describe el recorrido de tokens del archivo session.refresh.http y explica por qué el paso 5 también devuelve 401.

Respuesta razonada 1. Login → tokens A (access) y R1 (refresh). 2. Refresh con R1 → tokens nuevos; R1 queda `used`, se emite R2. 3. El access nuevo sirve en `/api/sesion/perfil`. 4. Se reenvía **R1** (ya usado) → el sistema lo interpreta como **reutilización**: `401` y **revoca toda la familia**. 5. Se prueba **R2**, que era válido: como la familia fue revocada en el paso 4, también devuelve `401`. Ese encadenamiento es precisamente lo que demuestra que la detección de reúso es «de familia», no del token individual.

Ejercicio 3 — Distingue modalidades. Para cada petición, indica la modalidad y el resultado esperado: (a) GET /api/sesion/perfil sin token; (b) POST /api/sesion/logout con un refresh válido; (c) GET /api/permisos con token de seller; (d) POST /api/clientes con token de seller.

Respuesta razonada - (a) **JWT** sin credencial → `401` (falta autenticación). - (b) **OPEN** con credencial de sesión → `200 { message: "Session closed" }`; idempotente. - (c) **JWT** → `200` con la lista de **7** recursos de `seller`. - (d) **JWT + RBAC** → `403` (autenticado, sin concesión). Observa que (a) y (d) son ambos rechazos, pero con significados distintos: `401` es «no sé quién eres»; `403` es «sé quién eres, y no puedes».

Glosario

Término Significado
OPEN Modalidad sin identidad previa (sin access token); puede tener su propia credencial en el cuerpo.
JWT Modalidad que exige un access token validado por authenticate.
JWT + RBAC Modalidad que exige token y una concesión en la matriz (authorize).
Access token JWT de vida corta que viaja en Authorization: Bearer.
Refresh token Token opaco de vida larga, guardado como hash; solo se devuelve en claro en login/refresh.
Rotación Al renovar, el refresh presentado se invalida y se emite uno nuevo de la misma familia.
Detección de reúso Si se presenta un refresh ya rotado, se revoca toda la familia.
Familia (family_id) Conjunto de refresh tokens de una misma sesión.
Ventana deslizante La vigencia del refresh se reinicia con cada rotación.
Permisos efectivos Pares (method, path) que resultan de la matriz RBAC para un usuario.
deviceInfo Fragmento del User-Agent (máx. 500) guardado para auditoría.

GATE

Para cerrar el ISS-15, comprueba primero los tipos:

npx tsc --noEmit

Resultado esperado: sin errores.

Después siembra y arranca:

npm run db:seed && npm run dev

Con el servidor en marcha, ejecuta el recorrido E2E de las tres modalidades:

BASE=http://localhost:4000

# 1) OPEN — login (no requiere identidad)
curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
  -d '{"identifier":"admin","password":"Admin123!"}'

# 2) JWT — perfil con el access token (sin permisos RBAC de por medio)
ADMIN=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
  -d '{"identifier":"admin","password":"Admin123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/sesion/perfil

# 3) JWT + RBAC — el mismo token, ahora sí, autoriza negocio
curl -s -H "Authorization: Bearer $ADMIN" $BASE/api/clientes

# 4) JWT + RBAC (denegado) — seller no tiene POST /api/clientes
SELLER=$(curl -s -X POST $BASE/api/sesion/login -H "Content-Type: application/json" \
  -d '{"identifier":"seller","password":"Seller123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).access_token")
curl -i -X POST -H "Authorization: Bearer $SELLER" -H "Content-Type: application/json" \
  -d '{"name":"x","phone":"1","email":"x@x.com","password":"x"}' $BASE/api/clientes   # 403

# 5) Renovación — rotar el refresh token
curl -s -X POST $BASE/api/sesion/refresh -H "Content-Type: application/json" \
  -d '{"refresh_token":"<refresh_token del login>"}'

Resultado esperado (tabla del ISS):

Comprobación Esperado
POST /api/sesion/login con credenciales válidas 200 + access_token + refresh_token
POST /api/sesion/login con contraseña errónea 401 genérico
GET /api/sesion/perfil sin token 401
GET /api/permisos con token de seller lista de 7 recursos
POST /api/clientes con token de seller 403
POST /api/sesion/refresh con el refresh válido 200 + nuevos tokens; el anterior queda used
Reusar el refresh anterior 401 + familia revocada

Detén el servidor con Ctrl+C antes de continuar.

Checklist de cierre:

  • [ ] Criterios 20.1 … 20.7 cumplidos
  • [ ] Las tres modalidades observables con curl (401 sin token, 403 sin concesión, 200/201 con ella)
  • [ ] GET /api/permisos refleja la matriz real (58 para admin, 7 para seller)
  • [ ] La rotación de refresh invalida el token anterior
  • [ ] npx tsc --noEmit sin errores y npm run dev arranca

Con todos en verde, el ISS-15 está cumplido y puedes pasar al Cierre de Fase II, donde se registran las rutas del feature (incluido SessionRoutes) en la App y se cierra la fase de autenticación.


Anexo — Pruebas E2E de los endpoints de Business bajo las 3 modalidades de acceso

Cuándo leer este anexo. El GATE del ISS-15 ya está cerrado. Este anexo es práctica de verificación: coge los endpoints de negocio (/api/clientes, /api/productos, /api/ventas…) y los somete a las tres modalidades de acceso —OPEN, JWT y JWT + RBAC—, primero con casos que deben funcionar y después provocando errores de todo tipo.

Complementa a ../../auth-3-modalidades-errores.md, que es la guía de referencia de los tres accesos y sus errores: aquí esa guía se aplica al negocio y se ejecuta de verdad.

Pregunta que responde: ¿cómo se comporta un endpoint de negocio cuando lo llamo sin credenciales, con identidad pero sin permiso, y con identidad y permiso? ¿y qué error exacto veo en cada fallo?

A.1 La idea que hay que entender antes de empezar

En este backend la modalidad no es global: se decide ruta a ruta, según qué middlewares se monten. Y hay un hecho que suele pillar desprevenido:

Ningún endpoint de negocio es OPEN. Todos los features de Business montan authenticate y authorize. Es decir: los endpoints de negocio son, sin excepción, modalidad JWT + RBAC.

Eso significa que la modalidad OPEN y la modalidad JWT no se usan en negocio, pero sí se observan en negocio: son los dos primeros peldaños de la escalera que una petición de negocio tiene que subir. Ese es justo el interés pedagógico del anexo: ver los tres estados aplicados al mismo recurso.

flowchart LR
  subgraph OPEN["Modalidad 1 — OPEN (solo sesion y docs)"]
    O1["POST /api/sesion/login"]
    O2["POST /api/sesion/refresh"]
    O3["GET /api/docs"]
  end
  subgraph JWT["Modalidad 2 — JWT (identidad, sin RBAC)"]
    J1["GET /api/sesion/perfil"]
    J2["GET /api/permisos"]
    J3["GET /api/sesiones"]
  end
  subgraph RBAC["Modalidad 3 — JWT + RBAC (todo el negocio)"]
    R1["GET /api/clientes"]
    R2["POST /api/ventas"]
    R3["GET /api/productos"]
    R4["GET /api/usuarios"]
  end
  OPEN -->|"emite el access_token"| JWT
  JWT -->|"el MISMO token, un paso mas"| RBAC
  RBAC -.->|"una concesion mas y entra"| R2

Pregunta que responde: ¿en qué modalidad está cada endpoint y cómo se encadena una modalidad con la siguiente usando el mismo token?

La consecuencia práctica, y el motivo de que el anexo pruebe las tres:

Lo que envías Qué espera el servidor que demuestres Resultado en un endpoint de negocio
Nada — 401 Missing Bearer token
Un token válido quién eres 403 si no tienes concesión · 200/201 si la tienes
Un token inválido — 401

A.2 Preparación: un laboratorio determinista

Antes de medir nada, hay que fijar el estado. Estos dos comandos dejan la matriz RBAC reproducible (son los mismos del GATE del ISS-15):

npm install
npm run db:seed     # deja la matriz determinista: ADMIN 58, SELLER 7
npm run dev         # http://localhost:4000
Usuario Contraseña Rol Permisos
admin Admin123! ADMIN 58 (todos)
seller Seller123! SELLER 7 (lecturas + registrar ventas)

Y las dos credenciales con las que se trabaja en todo el anexo:

BASE=http://localhost:4000

ADMIN=$(curl -s -X POST $BASE/api/sesion/login -H 'Content-Type: application/json' \
  -d '{"identifier":"admin","password":"Admin123!"}' | jq -r .access_token)

SELLER=$(curl -s -X POST $BASE/api/sesion/login -H 'Content-Type: application/json' \
  -d '{"identifier":"seller","password":"Seller123!"}' | jq -r .access_token)

Pregunta que responde: ¿por qué hay que resembrar antes de probar y qué estado deja el db:seed?

Lo que el servidor cree que puedes hacer

Antes de provocar un 403 conviene saber qué permisos tiene realmente cada usuario. GET /api/permisos responde con la misma consulta que usa authorize, así que es la herramienta de depuración de referencia:

curl -s $BASE/api/permisos -H "Authorization: Bearer $ADMIN"  | jq '.permissions | length'   # 58
curl -s $BASE/api/permisos -H "Authorization: Bearer $SELLER" | jq '.permissions | length'   # 7

Salida real de este laboratorio (SELLER completo, y el negocio de ADMIN):

SELLER (7):
  GET  /api/clientes
  GET  /api/clientes/:id
  GET  /api/productos
  GET  /api/productos/:id
  GET  /api/ventas
  GET  /api/ventas/:id
  POST /api/ventas

ADMIN  (58): los 7 anteriores y, además, todo el CRUD de
  clientes · tipos-producto · productos · usuarios · roles ·
  recursos · asignaciones-rol · concesiones-rol

Fíjate en el detalle que decide muchas pruebas: SELLER sí tiene GET /api/clientes/:id. Por eso GET /api/clientes/abc con token de seller devuelve 400 (pasa la autorización y falla la validación del :id), y no un 403. Volveremos sobre esto en §A.5.

A.3 Modalidad 1 — OPEN, aplicada a negocio

Sin cabecera Authorization. Un endpoint de negocio no perdona la falta de identidad.

Caso Petición Código real Cuerpo real
Listar clientes sin token GET /api/clientes 401 {"error":"Missing Bearer token"}
Crear cliente sin token POST /api/clientes 401 {"error":"Missing Bearer token"}
Listar productos sin token GET /api/productos 401 {"error":"Missing Bearer token"}
Listar ventas sin token GET /api/ventas 401 {"error":"Missing Bearer token"}
Listar usuarios sin token GET /api/usuarios 401 {"error":"Missing Bearer token"}
Ver mis permisos sin token GET /api/permisos 401 {"error":"Missing Bearer token"}

Contraste: lo que sí es OPEN en este backend. Para que el 401 no se confunda con «el servidor está roto», comprueba que las rutas OPEN sí responden sin credenciales:

Caso Petición Código real Nota
Documentación GET /api/docs 301 → 200 redirige a /api/docs/ (Location: /api/docs/); con curl -L son 200
Iniciar sesión POST /api/sesion/login 200 devuelve access_token + refresh_token
# 401 en negocio (no hay identidad)
curl -i $BASE/api/clientes

# 301 + 200 en documentación (OPEN de verdad): usa -L para seguir el redirect
curl -s -o /dev/null -w '%{http_code}\n'    $BASE/api/docs    # 301
curl -s -o /dev/null -w '%{http_code}\n' -L $BASE/api/docs    # 200

Pregunta que responde: ¿un endpoint de negocio se puede llamar sin credenciales, y cómo distingo ese 401 de un servidor mal arrancado?

A.4 Modalidad 2 — JWT: identidad sin RBAC

Esta modalidad existe de verdad, pero fuera del negocio: GET /api/sesion/perfil, GET /api/permisos y GET /api/sesiones solo piden quién eres, no comprueban concesiones. Es la modalidad que hay que dominar porque es el primer peldaño de cualquier llamada de negocio.

Casos correctos

Caso Petición Código real Cuerpo real (recortado)
Perfil con token de admin GET /api/sesion/perfil 200 {"user":{"id":1,"username":"admin","email":"admin@storelab.local","avatar":null,"status":"active"}}
Perfil con token de seller GET /api/sesion/perfil 200 mismo formato, con los datos de seller
Permisos de admin GET /api/permisos 200 58 elementos
Permisos de seller GET /api/permisos 200 7 elementos

Errores simulables: tokens fabricados

Aquí está la parte interesante: authenticate no se fía del token. Fija el algoritmo en código, exige iss y aud, y comprueba a mano los claims sub y jti. Para probarlo hay que fabricar tokens malos. Guárdalo como forge-tokens.mjs dentro del proyecto (para que resuelva jsonwebtoken):

forge-tokens.mjs
import jwt from "jsonwebtoken";
import { readFileSync } from "node:fs";

const SECRET = readFileSync(".env", "utf8").match(/^JWT_SECRET=(.*)$/m)[1].trim();
const ISS = "app-storelab-express";
const AUD = "app-storelab-api";
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");

const good = (o = {}) =>
  jwt.sign({ username: "admin" }, o.secret ?? SECRET, {
    algorithm: "HS256",
    subject: "1",
    issuer: o.issuer ?? ISS,
    audience: o.audience ?? AUD,
    expiresIn: o.expiresIn ?? 900,
    jwtid: o.jwtid === undefined ? "jti-ok" : o.jwtid,
  });

export const bad = {
  valid: good(),
  garbage: "no.es.un.jwt",
  wrongSecret: good({ secret: "otro-secreto-que-no-es-el-del-servidor-1234" }),
  wrongIssuer: good({ issuer: "otro-emisor" }),
  wrongAudience: good({ audience: "otra-audiencia" }),
  expired: good({ expiresIn: -60 }),
  // sin `jti`: se firma a mano omitiendo `jwtid`
  noJti: jwt.sign({ username: "admin" }, SECRET, {
    algorithm: "HS256", subject: "1", issuer: ISS, audience: AUD, expiresIn: 900,
  }),
  // sin `sub`
  noSub: jwt.sign({ username: "admin" }, SECRET, {
    algorithm: "HS256", issuer: ISS, audience: AUD, expiresIn: 900, jwtid: "jti-x",
  }),
  // firma válida, usuario inexistente
  ghostUser: jwt.sign({ username: "ghost" }, SECRET, {
    algorithm: "HS256", subject: "999999", issuer: ISS, audience: AUD,
    expiresIn: 900, jwtid: "jti-g",
  }),
  // alg: none (sin firma)
  algNone: `${b64({ alg: "none", typ: "JWT" })}.${b64({
    sub: "1", username: "admin", iss: ISS, aud: AUD,
    exp: Math.floor(Date.now() / 1000) + 900,
    iat: Math.floor(Date.now() / 1000), jti: "jti-none",
  })}.`,
  // token válido con el último carácter cambiado
  tampered: (() => { const t = good(); return t.slice(0, -1) + (t.slice(-1) === "A" ? "B" : "A"); })(),
};

console.log(JSON.stringify(bad, null, 2));
node forge-tokens.mjs > bad-tokens.json
T() { jq -r ".$1" bad-tokens.json; }

Resultados reales de este laboratorio contra GET /api/sesion/perfil:

Token enviado Código real Cuerpo real
(sin cabecera) 401 {"error":"Missing Bearer token"}
Authorization: Bearer (vacío) 401 {"error":"Missing Bearer token"}
Authorization: Token <válido> (esquema distinto) 401 {"error":"Missing Bearer token"}
no.es.un.jwt (basura) 401 {"error":"Invalid or expired access token"}
firma inválida (otro secreto) 401 {"error":"Invalid or expired access token"}
iss incorrecto 401 {"error":"Invalid or expired access token"}
aud incorrecta 401 {"error":"Invalid or expired access token"}
sin jti 401 {"error":"Invalid or expired access token"}
sin sub 401 {"error":"Invalid or expired access token"}
expirado 401 {"error":"Invalid or expired access token"}
alg: none (sin firma) 401 {"error":"Invalid or expired access token"}
payload manipulado (1 carácter) 401 {"error":"Invalid or expired access token"}
sub válido, usuario inexistente 401 {"error":"User is not active"}
TOKEN VÁLIDO (control) 200 el perfil del usuario

Tres observaciones que merecen atención:

  1. Los 401 no se distinguen entre sí por diseño. Firma mala, iss malo, expirado o manipulado devuelven el mismo mensaje: decir cuál falló sería regalar información a quien intenta atacar.
  2. «sin jti» y «sin sub» devuelven 401, no 200. Es la consecuencia del arreglo documentado en la guía de errores: jsonwebtoken no tiene opción require (esa es de jose), así que los claims obligatorios se comprueban explícitamente tras jwt.verify.
  3. El último caso es distinto y es el más sutil. User is not active significa que el token era criptográficamente válido pero el usuario revalidado en BD no está operable. No es un fallo del token, es un fallo de estado del usuario.
sequenceDiagram
  autonumber
  participant C as Cliente
  participant A as authenticate
  participant J as jwt.verify
  participant DB as Base de datos
  C->>A: "Authorization: Bearer eyJ..."
  A->>A: "extractBearerToken (esquema bearer, sin distinguir mayusculas)"
  alt "sin cabecera / Bearer vacio / otro esquema"
    A-->>C: "401 Missing Bearer token"
  else "hay token"
    A->>J: "verify con algorithms HS256 fijo, iss y aud"
    J-->>A: "payload o excepcion"
    alt "firma o claims invalidos"
      A-->>C: "401 Invalid or expired access token"
    else "payload valido"
      A->>A: "comprobar sub (entero positivo) y jti (no vacio)"
      A->>DB: "revalidar que el usuario existe y sigue active"
      alt "usuario inexistente o inactive"
        A-->>C: "401 User is not active"
      else "usuario activo"
        A->>C: "next() -> pasa a authorize"
      end
    end
  end

Pregunta que responde: ¿en qué orden y con qué criterio rechaza authenticate un token, y por qué unos fallos dan Missing Bearer token y otros Invalid or expired access token?

A.5 Modalidad 3 — JWT + RBAC sobre negocio

Es la modalidad de todos los endpoints de negocio. authorize corre después de authenticate y responde a una pregunta distinta: ¿puede esta identidad ejecutar method + path?

req.auth.user
  → role_users    (status = active, user_id = ?)
    → roles         (status = active)
      → resource_roles (status = active)
        → resources     (status = active, method = ?, path ≈ ?)

Los cuatro eslabones deben estar activos. Si cualquiera falla, la fila no aparece y el resultado es 403 (deny by default).

Casos correctos (con concesión activa)

Códigos reales de este laboratorio:

Petición admin seller
GET /api/clientes 200 200
GET /api/clientes/:id 200 200
POST /api/clientes 201 403
PUT /api/clientes/:id 200 403
PATCH /api/clientes/:id 200 403
DELETE /api/clientes/:id 200 403
GET /api/productos 200 200
GET /api/ventas 200 200
GET /api/detalle-ventas 200 403
GET /api/usuarios 200 403
GET /api/roles 200 403
GET /api/recursos 200 403

Un POST /api/clientes correcto devuelve:

{"client":{"id":20,"name":"E2E-1790688079","phone":"3001112233",
           "email":"e2e-1790688079@example.com","status":"active",
           "updatedAt":"2026-09-29T13:21:19.140Z","createdAt":"2026-09-29T13:21:19.140Z"}}

Observa que password no aparece en la respuesta: el DTO de salida lo elimina por proyección explícita (Omit).

Errores simulables: identidad válida sin concesión

El 403 es el error que más cuesta interiorizar, porque el token es perfecto. Mensajes reales:

Caso Petición Código real Cuerpo real
seller crea cliente POST /api/clientes 403 {"error":"Forbidden: no grant for POST /api/clientes"}
seller edita cliente PUT /api/clientes/20 403 {"error":"Forbidden: no grant for PUT /api/clientes/20"}
seller borra cliente DELETE /api/clientes/20 403 {"error":"Forbidden: no grant for DELETE /api/clientes/20"}
seller lista usuarios GET /api/usuarios 403 {"error":"Forbidden: no grant for GET /api/usuarios"}
seller lista detalle de ventas GET /api/detalle-ventas 403 {"error":"Forbidden: no grant for GET /api/detalle-ventas"}
seller lista concesiones GET /api/concesiones-rol 403 {"error":"Forbidden: no grant for GET /api/concesiones-rol"}

Detalle deliberado. El mensaje del 403 incluye el path real de la petición, no el patrón concesionable: no grant for DELETE /api/clientes/20. El patrón que se concede es /api/clientes/:id, pero el mensaje te dice exactamente qué URL falló. Es una decisión de facilidad de depuración, consciente del intercambio con la discreción.

flowchart TD
  A["Peticion a un endpoint de negocio"] --> B{"authenticate: hay Bearer valido?"}
  B -->|"no"| C["401 - no se quien eres"]
  B -->|"si"| D{"authorize: concesion activa para method + path?"}
  D -->|"no"| E["403 - se quien eres, pero no puedes"]
  D -->|"si"| F["controller -> service -> repository"]
  F --> G["200 / 201"]
  C -.->|"arreglo: enviar token"| H["GET /api/permisos"]
  E -.->|"arreglo: conceder el par (method, path)"| H
  H -.->|"la lista refleja la matriz real"| D

Pregunta que responde: ¿cómo distingo si un fallo es de token o de permiso, y qué hago en cada caso?

El 403 no depende del token: depende de la matriz

Esta es la prueba de que los roles y permisos no viajan dentro del token. Si se retira la concesión, el mismo token deja de autorizar de inmediato, sin reiniciar el servidor:

RID=$(curl -s $BASE/api/roles -H "Authorization: Bearer $ADMIN" | jq -r '.roles[]|select(.name=="SELLER")|.id')
ERC=$(curl -s $BASE/api/recursos -H "Authorization: Bearer $ADMIN" \
  | jq -r '.resources[]|select(.method=="POST" and .path=="/api/clientes")|.id')

# 1) conceder POST /api/clientes al rol SELLER -> el MISMO token pasa a poder
curl -s -X POST $BASE/api/concesiones-rol -H "Authorization: Bearer $ADMIN" \
  -H 'Content-Type: application/json' -d "{\"role_id\":$RID,\"resource_id\":$ERC}"

# 2) retirar la concesión -> vuelve a 403 al instante
GID=$(curl -s $BASE/api/concesiones-rol -H "Authorization: Bearer $ADMIN" | jq -r '.grants[-1].id')
curl -s -X PATCH $BASE/api/concesiones-rol/$GID/deactivate -H "Authorization: Bearer $ADMIN"

npm run db:seed   # restaurar la matriz original (ADMIN 58, SELLER 7)

Pregunta que responde: ¿por qué revocar un permiso surte efecto inmediato aunque el token siga vigente?

A.6 Errores transversales (400 / 404 / 409 / 500)

No son de la modalidad, pero aparecen constantemente al probar negocio.

Caso Petición Código real Cuerpo real
:id no entero GET /api/clientes/abc 400 {"error":"Invalid id: must be a positive integer"}
:id inexistente GET /api/clientes/999999 404 {"error":"Client not found"}
registro inactive GET de un cliente desactivado 404 {"error":"Client not found"}
JSON malformado POST /api/clientes con {"name": 400 {"error":"Malformed JSON body"}
ruta inexistente GET /api/no-existe 404 HTML de Express: Cannot GET /api/no-existe

El orden de los middlewares, verificado

Este es el matiz más fácil de equivocar, así que se probó en las dos direcciones:

authenticate  →  authorize  →  controller (paramId)  →  service
Caso Código real Por qué
seller + DELETE /api/clientes/abc 403 seller no tiene DELETE /api/clientes/:id → authorize corta antes de validar el :id
seller + GET /api/clientes/abc 400 seller sí tiene GET /api/clientes/:id → pasa la autorización y falla el :id
admin + GET /api/clientes/abc 400 admin tiene la concesión → llega al paramId

La lección: el 403 gana al 400. Si no tienes concesión, no llegas ni a que se valide tu entrada. Y por eso un :id inválido puede devolver dos códigos distintos según quién lo pida, sin ninguna contradicción.

Pregunta que responde: si mando un :id inválido en una ruta que no me corresponde, ¿qué error recibo primero, el de permiso o el de formato?

A.7 Hallazgos: dos defectos reales en los endpoints de negocio

Probar errores «raros» no es solo comprobar la API: es auditar. Ejecutar este anexo descubrió dos comportamientos reales de los endpoints de negocio que no se corresponden con lo que hacen los de autenticación. Se documentan tal cual, porque un cuaderno que solo enseña el camino feliz miente por omisión.

Hallazgo 1 — POST /api/clientes no valida campos obligatorios

Petición Código real Resultado real
POST /api/clientes con {} 201 {"client":{"id":26,"status":"active","updatedAt":"…","createdAt":"…"}} — creado con name, phone, email y password en null
POST /api/clientes con {"name":"SoloNombre"} 201 creado, el resto en null
POST /api/clientes con "email":"no-es-un-email" 500 {"error":"Internal server error","detail":"SequelizeValidationError: Validation error: Email must be a valid email address"}

Es decir: el endpoint acepta y persiste un cliente vacío (basura en la base de datos), pero si el email trae un formato inválido, en vez de un 400 de validación se obtiene un 500 con el nombre del error de Sequelize.

Compáralo con lo que sí hacen otros features del mismo proyecto:

Endpoint Petición inválida Código real
POST /api/productos {} 404 {"error":"Product type not found"}
POST /api/ventas {} 400 {"error":"Sale requires at least one item"}

O sea: products y sales sí validan su entrada (y devuelven 404/400 razonables), mientras clients no. La inconsistencia está localizada en el feature clients.

Hallazgo 2 — el email duplicado en clients devuelve 500 en vez de 409

Feature Caso Código real Cuerpo real
clients dos clientes con el mismo email 500 {"error":"Internal server error","detail":"SequelizeUniqueConstraintError: Validation error"}
users username duplicado 409 {"error":"Username already in use"}
users email duplicado 409 {"error":"Email already in use"}
roles name duplicado 409 {"error":"Role name already in use"}
recursos (method, path) duplicado 409 {"error":"Resource GET /api/clientes already exists"}

Los features de autenticación traducen el conflicto de unicidad a un 409 con mensaje de negocio. clients no lo traduce: deja escapar el error del ORM como 500.

Por qué esto importa (más allá de la estética)

  1. Fuga de información. El campo detail expone el nombre de la capa de persistencia (SequelizeUniqueConstraintError, SequelizeValidationError). Un cliente de la API no debería saber que por debajo hay Sequelize, y menos aún el nombre interno de la restricción que falló.
  2. Semántica HTTP incorrecta. Un conflicto de unicidad es 409, y una entrada mal formada es 400. Devolver 500 dice «el servidor está roto», cuando en realidad la petición era inválida: eso desvía el diagnóstico y ensucia la monitorización.
  3. Integridad de datos. Un 201 con todos los campos en null es peor que un error: mete ruido en la base de datos y no avisa a nadie.
  4. Respuesta poco útil. El detail de un registro recién creado solo muestra id, status y timestamps: no confirma al cliente qué se guardó.

Pregunta que responde: ¿qué comportamientos de los endpoints de negocio no coinciden con los de autenticación, y por qué son un problema real y no un detalle cosmético?

Nota de honestidad. Estos dos hallazgos se descubrieron ejecutando el anexo, no leyendo el código. Eso es exactamente lo que justifica simular errores: la documentación y el comportamiento real divergen, y solo ejecutar lo revela.

A.8 Script integral reproducible

Guarda este script como scripts/verify-business-access.sh y ejecútalo con el servidor levantado y la base de datos recién sembrada. Recorre las tres modalidades sobre endpoints de negocio y termina en verde solo si todos los códigos son los esperados.

#!/usr/bin/env bash
# Verifica los endpoints de NEGOCIO bajo las 3 modalidades de acceso.
set -u
BASE="http://localhost:4000"
PASS=0; FAIL=0

c()   { curl -s -o /dev/null -w '%{http_code}' "$@"; }
chk() { if [ "$2" = "$3" ]; then printf '  PASS  [%s] %s\n' "$3" "$1"; PASS=$((PASS+1));
        else printf '  FAIL  esperado=%s obtenido=%s :: %s\n' "$2" "$3" "$1"; FAIL=$((FAIL+1)); fi; }
login() { curl -s -X POST "$BASE/api/sesion/login" -H 'Content-Type: application/json' -d "$1"; }

AT=$(login '{"identifier":"admin","password":"Admin123!"}'  | jq -r .access_token)
ST=$(login '{"identifier":"seller","password":"Seller123!"}' | jq -r .access_token)
AU="Authorization: Bearer $AT"
SU="Authorization: Bearer $ST"
J='Content-Type: application/json'

echo "=== Modalidad 1 — OPEN (negocio NO es OPEN) ==="
chk "sin token GET  /api/clientes"  401 "$(c "$BASE/api/clientes")"
chk "sin token POST /api/clientes"  401 "$(c -X POST "$BASE/api/clientes" -H "$J" -d '{}')"
chk "sin token GET  /api/productos" 401 "$(c "$BASE/api/productos")"
chk "sin token GET  /api/ventas"    401 "$(c "$BASE/api/ventas")"
chk "sin token GET  /api/usuarios"  401 "$(c "$BASE/api/usuarios")"
echo "--- contraste: lo que SÍ es OPEN ---"
chk "GET /api/docs (redirect)"      301 "$(c "$BASE/api/docs")"
chk "GET /api/docs (siguiendo)"     200 "$(c -L "$BASE/api/docs")"
chk "POST /api/sesion/login"        200 "$(c -X POST "$BASE/api/sesion/login" -H "$J" -d '{"identifier":"admin","password":"Admin123!"}')"

echo "=== Modalidad 2 — JWT (identidad sin RBAC) ==="
chk "perfil con token admin"        200 "$(c "$BASE/api/sesion/perfil" -H "$AU")"
chk "perfil con token seller"       200 "$(c "$BASE/api/sesion/perfil" -H "$SU")"
chk "perfil sin token"              401 "$(c "$BASE/api/sesion/perfil")"
chk "perfil token basura"           401 "$(c "$BASE/api/sesion/perfil" -H 'Authorization: Bearer basura')"
chk "admin 58 permisos"              58 "$(curl -s "$BASE/api/permisos" -H "$AU" | jq '.permissions|length')"
chk "seller 7 permisos"               7 "$(curl -s "$BASE/api/permisos" -H "$SU" | jq '.permissions|length')"

echo "=== Modalidad 3 — JWT + RBAC (con concesión) ==="
chk "admin  GET    /api/clientes"   200 "$(c "$BASE/api/clientes" -H "$AU")"
chk "seller GET    /api/clientes"   200 "$(c "$BASE/api/clientes" -H "$SU")"
chk "admin  POST   /api/clientes"   201 "$(c -X POST "$BASE/api/clientes" -H "$AU" -H "$J" -d '{"name":"verify","phone":"3000000000","email":"verify@example.com","password":"Secret123"}')"
chk "seller GET    /api/productos"  200 "$(c "$BASE/api/productos" -H "$SU")"
chk "seller GET    /api/ventas"     200 "$(c "$BASE/api/ventas" -H "$SU")"
chk "admin  GET    /api/detalle-ventas" 200 "$(c "$BASE/api/detalle-ventas" -H "$AU")"

echo "=== Modalidad 3 — JWT + RBAC (sin concesión) ==="
chk "seller POST   /api/clientes"   403 "$(c -X POST "$BASE/api/clientes" -H "$SU" -H "$J" -d '{"name":"x","phone":"1","email":"x@x.com","password":"p"}')"
chk "seller DELETE /api/clientes/1" 403 "$(c -X DELETE "$BASE/api/clientes/1" -H "$SU")"
chk "seller GET    /api/usuarios"   403 "$(c "$BASE/api/usuarios" -H "$SU")"
chk "seller GET    /api/detalle-ventas" 403 "$(c "$BASE/api/detalle-ventas" -H "$SU")"

echo "=== Errores transversales ==="
chk "admin :id no entero"           400 "$(c "$BASE/api/clientes/abc" -H "$AU")"
chk "admin :id inexistente"         404 "$(c "$BASE/api/clientes/999999" -H "$AU")"
chk "ruta inexistente"              404 "$(c "$BASE/api/no-existe" -H "$AU")"
chk "JSON malformado"               400 "$(c -X POST "$BASE/api/clientes" -H "$AU" -H "$J" -d '{"name":')"
chk "orden: seller DELETE :id invalido -> 403" 403 "$(c -X DELETE "$BASE/api/clientes/abc" -H "$SU")"
chk "orden: seller GET    :id invalido -> 400" 400 "$(c "$BASE/api/clientes/abc" -H "$SU")"

echo
echo "======== $PASS PASS / $FAIL FAIL ========"
npm run db:seed > /dev/null   # restaurar la matriz y los usuarios canónicos
[ "$FAIL" -eq 0 ]

Resultado real de la ejecución en este laboratorio (parte A del suite):

======== PARTE A: 49 PASS / 4 FAIL ========

Los 4 «FAIL» no eran fallos del backend: dos eran expectativas mal puestas en el script (el 301 de /api/docs y el hecho de que seller sí tiene GET /api/clientes/:id) y dos destaparon los defectos reales de §A.7. Ese es el valor de un script de verificación: cuando falla, o está mal el test o está mal el código, y averiguar cuál es el trabajo.

Pregunta que responde: ¿cómo convierto todo este anexo en una comprobación que se pueda repetir en un segundo?

A.9 Cómo se ve en Swagger

El documento OpenAPI fija security: [{ bearerAuth: [] }] globalmente, así que toda operación muestra un candado 🔒. Las OPEN lo anulan con security: []:

En /api/docs Significa
Sin candado OPEN
Con candado 🔒 JWT o JWT + RBAC

El candado no distingue entre JWT y JWT + RBAC; lo que distingue es el comportamiento: prueba la misma operación con seller y observa si responde 403. Swagger sirve para descubrir la modalidad; curl sirve para demostrarla.

A.10 Regla de oro para depurar un endpoint de negocio

401  ->  problema de TOKEN   (¿lo enviaste? ¿caducó? ¿el usuario sigue activo?)
403  ->  problema de PERMISO (GET /api/permisos: ¿aparece el par method + path?)
400  ->  problema de ENTRADA (body incompleto o mal formado, o :id no entero)
404  ->  problema de RECURSO (no existe, está inactive, o no es tuyo)
409  ->  CONFLICTO (duplicado... en los features que sí lo traducen: ver §A.7)
500  ->  o hay un fallo no previsto, o topaste con un defecto conocido (§A.7)

A.11 Checklist del anexo

  • [ ] Puedo explicar por qué ningún endpoint de negocio es OPEN.
  • [ ] Consigo los tres códigos sobre /api/clientes: 401 sin token, 403 con seller, 200 con admin.
  • [ ] Distingo por el mensaje un 401 de token (Missing Bearer token / Invalid or expired access token / User is not active).
  • [ ] Distingo un 401 de un 403 y sé qué mirar en cada caso.
  • [ ] Sé por qué DELETE /api/clientes/abc da 403 y GET /api/clientes/abc da 400.
  • [ ] Sé que SELLER tiene 7 permisos y ADMIN 58, y puedo listarlos con GET /api/permisos.
  • [ ] Puedo reproducir el 500 del email duplicado en clients y explicar el 409 de users.
  • [ ] Ejecuto scripts/verify-business-access.sh y termino con npm run db:seed.

Con este anexo cerrado, sabes probar lo que el ISS-15 construyó: las tres modalidades conviviendo en el mismo backend, con el negocio detrás de la tercera.


Navegación de la ruta: ← ISS-14 · 🛠 Construir · ↑ Ruta Express · → ISS-15 · 🛠 Construir