diff --git a/docs/qa/informe-pruebas-sprint1.md b/docs/qa/informe-pruebas-sprint1.md new file mode 100644 index 0000000..d934f99 --- /dev/null +++ b/docs/qa/informe-pruebas-sprint1.md @@ -0,0 +1,383 @@ +# Informe de calidad — cobertura de pruebas del Sprint 1 + +Trabajo de QA sobre la rama `feature/qa-unit-tests`, sacada de `main`. No se modificó lógica de +negocio: todo lo añadido vive bajo `src/test/java`, salvo la configuración de medición de cobertura +en `pom.xml`. + +## Resultado + +| Métrica | Antes | Después | +|---|---|---| +| Pruebas | 48 | **1080** (982 unitarias + 98 de integración) | +| Instrucciones | 71,5 % | **100 %** (2976/2976) | +| Ramas | 45,5 % | **100 %** (156/156) | +| Líneas | 65,0 % | **100 %** (678/678) | +| Métodos | — | **100 %** (163/163) | +| Clases medidas | 38 | **52** | + +La única clase excluida de la medición es `BookingPlatformApplication`: su `main()` solo delega en +`SpringApplication.run` y cubrirlo exigiría levantar el contexto entero sin probar nada propio. Las +ocho clases que no aparecen en el informe son interfaces y `ClientEntity`, que no tienen bytecode +propio que medir. + +El `pom.xml` incorpora un umbral del 90 % en instrucciones **y** en ramas que hace fallar el build +por debajo de esa cifra. Se fija en 90 y no en el 100 actual para dejar margen al mantenimiento. +El umbral está comprobado en los dos sentidos: pasa con la suite completa y rompe el build cuando +la cobertura cae. + +### Una nota sobre el número de pruebas + +La suite llegó a tener 1046 casos unitarios y **se recortaron 156**, que probaban enums, `record` y +comportamiento del propio lenguaje: que `valueOf` hace *round-trip*, que el `equals` generado por el +compilador funciona. `ErrorCodeTest` tenía 85 casos para un enum de dieciséis valores. + +Al borrarlos **la cobertura no se movió del 100 %**, lo que demuestra que no cubrían nada que no +estuviera ya cubierto: solo añadían tiempo de ejecución y mantenimiento. El criterio de este +trabajo es que una prueba valga por la regla que verifica, no por el porcentaje que empuja. + +### Cómo reproducirlo + +```bash +export JAVA_HOME= +./mvnw clean verify +``` + +El informe queda en `target/site/jacoco/index.html`. El `clean` no es opcional: sin él el fichero de +ejecución acumula corridas anteriores y la cifra sale inflada. + +Dos notas de entorno. El proyecto exige **Java 21**: con un JDK 17 el build falla con +`release version 21 not supported`. Y las pruebas de integración necesitan **Docker** para +Testcontainers; se saltan con `-DskipITs`, pero entonces la cobertura que se mide es solo la de las +unitarias. + +## Cómo se probó, y por qué así + +La cobertura por sí sola no dice nada: se puede recorrer el 100 % de las líneas sin probar una sola +de las decisiones que toma el código. Por eso el criterio no fue tocar líneas sino aplicar técnicas +formales, y la métrica que importa aquí es la de **ramas**, que partía del 45,5 %. + +**Valores límite.** Donde hay un umbral, se prueba justo antes, justo en él y justo después. El +bloqueo por intentos fallidos en `max-1`, `max` y `max+1`. La longitud de contraseña en 7, 8, 71, 72 +y 73. La mayoría de edad el día antes del cumpleaños, el día mismo y el día después, más un nacido +un 29 de febrero. La ventana deslizante del bloqueo en su borde exacto: un intento que cae en +`now - lockWindow` cuenta, uno un milisegundo antes no, y uno con fecha posterior a `now` se +descarta. + +**Tabla de decisión.** `GoTrueClient.mapError` traduce las respuestas del proveedor de identidad al +vocabulario de errores del dominio, y cada traducción equivocada se convierte en un código HTTP +equivocado. Se probó como lo que es: 30 filas sobre (contexto, estado HTTP, cuerpo), más 6 filas +cuyo único fin es fijar **en qué orden** se disparan las reglas. Ahí apareció el defecto D2. + +**Transición de estados.** `Client` es una máquina de estados y se probó su tabla completa 3×3: los +tres estados contra las tres operaciones, las nueve celdas, incluidas las cuatro que deben rechazar +y las dos que son idempotentes. En las que rechazan se verifica además que el estado no se mutó. + +**Verificación de interacciones.** Varias reglas de negocio no son sobre lo que pasa, sino sobre lo +que **no** debe pasar: una cuenta bloqueada nunca llega al proveedor de identidad, una contraseña +débil tampoco, y un correo desconocido en el reenvío ni llama fuera ni lanza, que es justo lo que +mantiene cerrada la enumeración de usuarios. + +**Propiedades de seguridad como pruebas.** Tres reglas que nadie había escrito quedan ahora fijadas: +la clave secreta solo viaja a `/admin/**`; el rol solo se lee de `app_metadata` y un `role` puesto en +`user_metadata`, que el cliente sí puede editar, se ignora; y el manejador de errores no filtra el +mensaje interno de la excepción, comprobado pasándole una cadena de conexión con contraseña dentro. + +## Defectos encontrados + +Nueve defectos, más un grupo de hallazgos menores. Cinco se corrigieron con cambios quirúrgicos, +verificando la suite completa después de cada uno; el resto se deja documentado porque arreglarlos +exige decisiones que no corresponden a QA, o porque el riesgo de tocarlos ahora es mayor que el +beneficio. + +| | Defecto | Estado | +|---|---|---| +| D1 | Fallo del proveedor en recuperación de contraseña sale como 500 | **Corregido** | +| D2 | Un enlace de verificación caducado dice «usuario no encontrado» | **Corregido** | +| D3 | Una respuesta inesperada del proveedor se convierte en 500 | **Corregido** | +| D4 | Un correo ausente se convierte en la cadena literal `"null"` | **Cerrado a propósito sin exigirlo** | +| D5 | El rol del JWT se normaliza sin `Locale` | **Corregido** | +| D6 | El dominio depende de Spring y ArchUnit no lo ve | Abierto — cambio de arquitectura | +| D7 | Registro concurrente: 500 en vez de 409, y sin compensación | Abierto — reestructura la transacción | +| D8 | El cierre de sesión no invalida el token de acceso | Abierto — decisión de producto (ADR-0003) | +| D9 | Spring MVC no puede traducir sus propios códigos de estado | Abierto — decisión de diseño | + +Las pruebas de los defectos abiertos fijan el comportamiento **actual** y fallarán en cuanto alguien +los corrija, que es exactamente la señal que se busca. + +### D1 — Un fallo del proveedor en recuperación de contraseña sale como 500 + +`PasswordRecoveryUseCase.java:33` y `PasswordResetUseCase.java:39` relanzan la +`UpstreamAuthException` cruda. `GlobalExceptionHandler` no declara ningún manejador para esa +excepción, así que cae en el catch-all y se responde **500 INTERNAL_ERROR**. + +Esperado: `RATE_LIMITED` a 429 y `UNAVAILABLE` a 502, que es lo que sí hacen `LoginUseCase`, +`LogoutUseCase` y `UserProvisioningService`. + +Hay una segunda consecuencia, peor que el código de estado. El Javadoc de la clase promete que la +recuperación responde siempre igual para no delatar si un correo existe. Con el proveedor limitando +por volumen, la respuesta pasa de 202 a 500 y **la anti-enumeración se rompe**. + +### D2 — Un enlace de verificación caducado dice «usuario no encontrado» + +En `GoTrueClient.java:245`, la regla `body.contains("not found") || status == 404` se evalúa **antes** +que la de `expired` y antes del `switch (context)`. GoTrue responde 404 a un OTP caducado o ya +consumido, así que quien pincha un enlace vencido recibe `USER_NOT_FOUND` en vez de `TOKEN_EXPIRED`. + +Arreglo: subir la comprobación de `expired` y el `switch` por encima de la regla del 404, o acotar +`status == 404` a los contextos administrativos. + +### D3 — Una respuesta inesperada del proveedor se convierte en 500 + +Dos casos en `GoTrueClient`, los dos por la misma causa: el `try` solo captura +`RestClientResponseException` y `ResourceAccessException`, de modo que cualquier otra excepción +escapa sin mapear. + +- `GoTrueClient.java:97` — `Long.parseLong` sobre `expires_in`. Un valor no numérico, o decimal como + `3600.0` que es JSON perfectamente válido, lanza `NumberFormatException` cruda. +- `GoTrueClient.java:59` y `:120` — `UUID.fromString` sobre el `id` devuelto. Un identificador + malformado lanza `IllegalArgumentException` cruda. + +### D4 — Un correo ausente se convierte en la cadena literal `"null"` + +`GoTrueClient.java:121` hacía `String.valueOf(user.get("email"))`, de modo que una respuesta sin +correo producía `ConfirmedUser.email()` valiendo `"null"`, cuatro caracteres, viajando como si +fuera una dirección. No es una excepción: es corrupción silenciosa. + +Se cerró **a propósito sin exigir el campo**. El primer intento fue reclamarlo con `requireField`, +como se hace con el `id`, pero al revisarlo se vio que **ese correo no lo lee nadie**: +`ConfirmEmailUseCase` resuelve el cliente por su identificador. Exigirlo convertiía una respuesta +sin ese campo en un 502 por un valor que se descarta, o sea que en el único caso en que los dos +comportamientos difieren, el nuevo era peor. Ahora se devuelve `null` —nunca la cadena `"null"`— +y el campo sigue siendo opcional. + +### D5 — El rol del JWT se normaliza sin `Locale` + +`SupabaseJwtAuthConverter.java:32` hace `roleValue.toUpperCase()` sin `Locale`. Bajo locale turco, +`admin` se convierte en `ADMİN` con i sin punto, con lo que `ROLE_ADMİN` no coincide con `ROLE_ADMIN` +y la autorización falla en silencio. + +No es un descuido aislado: las otras once normalizaciones del proyecto sí usan `Locale.ROOT`. El +mismo patrón aparece en `GlobalExceptionHandler.java:86` y `SecurityConfig.java:106`, donde un +`toLowerCase()` sin locale altera el URI del tipo de error. + +### D6 — El dominio depende de Spring, y ArchUnit no lo ve + +`Client.java` importa `ErrorCode`, y `ErrorCode.java:3` importa `org.springframework.http.HttpStatus`. +El dominio queda acoplado al framework, contra lo que fija el ADR-0001. + +La regla `DOMAIN_IS_FREE_OF_FRAMEWORKS` no lo detecta porque `dependOnClassesThat()` solo inspecciona +dependencias **directas**. La violación existe y el build sigue en verde, que es el peor de los casos: +una regla que da confianza sin darla. + +### D7 — Registro concurrente: 500 en vez de 409, y sin compensación + +`RegisterClientUseCase.java:46-75` comprueba y luego actúa: `existsByEmail` / `existsByDocument` y +después `save`. Dos peticiones simultáneas con el mismo correo pasan las dos comprobaciones. + +La `DataIntegrityViolationException` que sale de la restricción única no está mapeada, así que se +responde **500** en lugar de 409. Y hay un segundo efecto: como el identificador del cliente viene +asignado, el INSERT se ejecuta al confirmar la transacción, **después** de que el método retorne, de +modo que el `catch` de la línea 72 no se dispara y **la compensación no ocurre**: queda un usuario +huérfano en el proveedor de identidad. + +Este último punto es el único de la lista que **no está verificado con una prueba**: requiere una de +integración contra la restricción real. Queda como la primera tarea pendiente. + +### D8 — El cierre de sesión no invalida el token de acceso + +`LogoutUseCase` revoca la sesión en el proveedor y eso funciona: la llamada llega. Pero el token de +acceso es un JWT autocontenido que se valida sin preguntar a nadie, así que **sigue sirviendo hasta +que expira**. Comprobado de punta a punta: tras cerrar sesión, `GET /api/v1/auth/me` con el mismo +token devuelve 200. + +El ADR-0003 acepta explícitamente ese compromiso —«un access token robado sigue siendo válido hasta +su expiración corta»—, así que no es una sorpresa. Pero el criterio de HU-021 está redactado en +absoluto, de modo que a efectos de trazabilidad queda **parcialmente cumplido**, no cumplido. + +### D9 — Spring MVC no puede traducir sus propios códigos de estado + +`GlobalExceptionHandler` está anotado `@Order(Ordered.HIGHEST_PRECEDENCE)` y declara +`@ExceptionHandler(Exception.class)`. Eso lo coloca por delante de la traducción propia de Spring +MVC, que deja de aplicarse: + +| Petición | Esperado | Real | +|---|---|---| +| `GET /api/v1/registrations` (el endpoint es POST) | 405 con cabecera `Allow` | **500 `INTERNAL_ERROR`**, sin `Allow` | +| `POST /api/v1/registrations` con `Content-Type: text/plain` | 415 | **500** | +| `GET` a una ruta pública inexistente | 404 | **500** | + +Reproducido en las pruebas de integración y de nuevo contra la aplicación levantada en Docker. + +Hay un efecto colateral que ensucia la operación: cada uno de estos errores corrientes de cliente se +registra a nivel `ERROR` con traza completa, así que un cliente mal configurado parece una avería +del servidor y dispara alertas falsas. + +Arreglo sugerido: que `GlobalExceptionHandler` extienda `ResponseEntityExceptionHandler`, o quitarle +`@Order(HIGHEST_PRECEDENCE)` y dejar el `Exception.class` en un advice de menor precedencia. No se +aplicó porque cambia el orden de todo el manejo de errores y eso no se toca la víspera de una +entrega. + +### Hallazgos menores + +| Dónde | Qué | +|---|---| +| `RegisterClientUseCase.java:45` | El correo se pasa a minúsculas pero no se hace `trim()`, al contrario que nombre, documento, teléfono y ciudad | +| `RegisterClientUseCase.java:72-75` | Si la compensación falla, su excepción sustituye a la original y se pierde el motivo real | +| `RegisterClientUseCase.java:69` | Llamada HTTP al proveedor **dentro** de `@Transactional`: retiene conexión del pool, y el correo sale aunque la transacción revierta | +| `LoginRequest.java:11` | `password` sin `@Size`; `RegisterClientRequest` y `PasswordResetRequest` sí topan en 72 | +| `RegisterClientRequest.java:22` | El patrón del documento acepta una cadena formada solo por guiones | +| `AuthController.java:90` | `UUID.fromString(jwt.getSubject())` sin guarda: un `sub` malformado da 500 | +| `GlobalExceptionHandler.java:47` | Dos violaciones sobre el mismo campo: la segunda sobrescribe a la primera y el cliente solo ve una | +| `GlobalExceptionHandler.java:87` | `URI.create(request.getRequestURI())` sin codificar: un carácter ilegal rompe el propio manejador | +| `GlobalExceptionHandler.java:62` vs `:36` | `handleValidation` publica `details` aunque esté vacío; `handleBusiness` lo omite | +| `LoginUseCase.java:51`, `GoTrueClient.java:234` | Correo del usuario y cuerpo completo de la respuesta del proveedor en logs de nivel `WARN` | +| `SupabaseProperties.java:6` | `secret-key` sin validación y con valor por defecto vacío: la aplicación arranca sin credencial y falla en caliente | +| `AuthPolicyProperties.java:6` | `lockWindowMinutes` sin cota: un valor negativo desplaza la ventana al futuro y **nadie se bloquea nunca** | +| `UpstreamAuthException.java:3`, `BusinessException.java:7,10` | Clases serializables sin `serialVersionUID`; `details` no transitorio con tipo no serializable (`javac -Xlint:all`) | + +### Comprobado y correcto + +Tres cosas que se sospechaban y **no** son defectos, verificadas expresamente: + +- **No hay escalada de privilegios por `user_metadata`.** El conversor solo lee `app_metadata`, y si + el rol aparece en ambos gana `app_metadata`. Comprobado en unitario y de extremo a extremo contra + la cadena de filtros real. +- **El filtro de trazas no fuga contexto entre peticiones.** El `finally` limpia el MDC también + cuando la cadena lanza, y la cabecera `X-Trace-Id` se devuelve incluso en respuestas fallidas. +- **El manejador de errores no filtra información interna.** Se le pasó una excepción cuyo mensaje + contenía una cadena de conexión con contraseña y la respuesta no lleva ni el mensaje, ni la clase + de la excepción, ni la traza. + +## Validación de arranque y despliegue + +Además de la suite, se ejecutó de verdad el camino de despliegue completo. Nada de esto se +comprobaba antes: no existía una sola prueba que verificase que el contexto de Spring carga. + +| Paso | Resultado | +|---|---| +| `mvnw clean package` | OK, jar de 66 MB | +| `docker build` | OK, 54 s, imagen de 274 MB, **sin avisos** | +| `docker compose up` | OK, la aplicación responde 11,5 s después de arrancar | +| Perfil `cloud` con `ddl-auto=validate` contra `schema.sql` | **OK** — el DDL del repo y el mapeo JPA concuerdan, el arranque en Render no muere por desajuste de esquema | +| Arranque real sobre Tomcat + PostgreSQL | OK, responde por HTTP | + +### Escenarios de negocio probados contra el contenedor + +No «que responda algo»: reglas de negocio con su código de estado y su `errorCode`. +**22 de 24 correctos**; las dos desviaciones son el mismo defecto D9. + +| Escenario | Esperado | Obtenido | +|---|---|---| +| Un menor de 18 no puede registrarse | 400 `MINOR_NOT_ALLOWED` | ✔ | +| Contraseña que no cumple la política | 400 `PASSWORD_TOO_WEAK` | ✔ | +| Correo, teléfono, documento y fecha inválidos | 400 `VALIDATION_ERROR` | ✔ (4 casos) | +| Canal de notificación inexistente, JSON malformado | 400 `VALIDATION_ERROR` | ✔ | +| Alta válida con el proveedor caído | 502 `UPSTREAM_AUTH_ERROR` | ✔ | +| Login y recuperación con el proveedor caído | 502 `UPSTREAM_AUTH_ERROR` | ✔ | +| Reseteo: clave corta la para `@Size`, clave larga sin mayúscula la para la política | 400 en cada caso, con su código distinto | ✔ | +| Perfil y cierre de sesión sin token, o con token basura | 401 `AUTH_REQUIRED` | ✔ | +| Ruta protegida inexistente | 401, no revela qué rutas existen | ✔ | +| `X-Trace-Id`: se genera si falta y se respeta si viene | presente en ambas | ✔ | +| **Método no permitido** | 405 con cabecera `Allow` | **500** (D9) | +| **Ruta pública inexistente** | 404 | **500** (D9) | + +### Riesgos de despliegue + +Ninguno impide compilar, empaquetar ni arrancar. Son de configuración, y los tres primeros +importan para una demostración en vivo. + +**R1 — Si falta `SUPABASE_SECRET_KEY`, el login miente sobre la causa.** El valor por defecto +es cadena vacía, así que la aplicación **arranca igual** y falla en la primera petición. Supabase +responde 401 y `GoTrueClient.mapError`, en contexto `token`, lo clasifica como +`INVALID_CREDENTIALS`: quien mira la pantalla lee «correo o contraseña inválidos» y busca el +problema donde no está. El mismo fallo en `/registrations` sale como 502, o sea la misma causa +con dos caras distintas. + +**R2 — CORS no funciona.** `render.yaml` declara `ALLOWED_ORIGINS`, pero **ningún código la lee**: +`SecurityConfig` llama a `.cors(Customizer.withDefaults())` y no existe ningún +`CorsConfigurationSource` propio. Comprobado en vivo: un *preflight* no devuelve +`Access-Control-Allow-Origin`. Un frontend en navegador queda bloqueado; por Postman o Swagger +no se nota. + +**R3 — El health check de Render no mira la base de datos.** `render.yaml` apunta a +`/actuator/health/readiness`, y ese grupo no incluye el indicador `db`. Render mantiene en +servicio una instancia con la base caída. `/actuator/health` sí baja a 503, pero Render no lo +consulta. + +**R4 — Tres variables declaradas que nadie lee.** `APP_BASE_URL` y `ALLOWED_ORIGINS` en +`render.yaml`; `JWT_ISSUER` y `JWKS_URI` en el README. Los nombres que sí funcionan son +`APP_SECURITY_JWT_ISSUER` y `APP_SECURITY_JWKS_URI`. + +**R5 — `DATABASE_USER` y `DATABASE_PASSWORD` caen a `postgres/postgres` en silencio** si faltan, +lo que en Render aparece como un fallo de autenticación en vez de un mensaje claro. + +**R6 — Una `SUPABASE_URL` con barra final** produce un emisor `…//auth/v1` y hace fallar la +validación de tokens perfectamente válidos. + +Los arreglos de R1, R2 y R3 están escritos pero **no aplicados**, y a propósito: el *fail-fast* de +la credencial cambia un fallo visible en el login por un servicio que no levanta, y meter la base +en `readiness` haría que un hipo del pooler saque la instancia de servicio. Las dos son mejoras +correctas y las dos suben el riesgo el día de una demostración. La decisión es del equipo. + +## Criterios de aceptación que siguen sin verificar + +| Criterio | Por qué no se pudo cerrar | +|---|---| +| Validación real del JWT: emisor, expiración, JWKS | Las pruebas usan el soporte de Spring Security, que salta el decoder. Un JWKS mal configurado solo se vería en producción | +| «Enlace de un solo uso» | Se prueba el token caducado, no el reúso del mismo token, que es la propiedad que da nombre al criterio | +| El cierre de sesión invalida la sesión | Se verifica que se invoca `signOut`, no que un token posterior sea rechazado | +| Un cliente no verificado no confirma reservas | La invariante existe en `Client.canConfirmBooking()` y está probada, pero **ningún código de producción la consulta**: no hay flujo de reservas todavía | +| MFA obligatorio para administradores | No implementado, diferido en el ADR-0003 | +| Correo o documento duplicado en concurrencia | Ver D7 | + +HU-002, HU-003 y HU-004 no tienen implementación en esta rama: 16 criterios de aceptación sin código +y, por tanto, sin prueba posible. Se registran como deuda visible, no como fallo del Sprint 1. + +## Mejoras de diseño propuestas + +Huecos que hoy dificultan probar. Cada uno está redactado como card, con su criterio de aceptación. + +**QA-01 — El dominio incumple ADR-0001 y la regla que debería impedirlo no lo ve.** +Ver D6. *Criterio:* el dominio de `identity` y `auth` no depende de Spring ni directa ni +transitivamente, y existe una regla ArchUnit que falla si alguien reintroduce el acoplamiento. +*Esfuerzo:* M. + +**QA-02 — Las políticas de negocio son `static` y no se pueden sustituir.** +`PasswordPolicy.violations()` y `AgePolicy.isAdult()` son estáticos en clases `final`, invocados +desde tres casos de uso. No se puede probar `RegisterClientUseCase` con una política de edad falsa: +toda prueba arrastra la regla real. *Criterio:* ambas se inyectan como colaboradores y existe una +prueba que sustituye `AgePolicy` por un doble. *Esfuerzo:* M. + +**QA-03 — Los controladores dependen de clases concretas, no de interfaces.** +`AuthController` y `RegistrationController` importan las implementaciones de los casos de uso, +mientras que `auth` sí publica `UserProvisioning` como interfaz. La asimetría no tiene motivo. +*Criterio:* cada caso de uso consumido por un controlador se expone tras una interfaz, y una regla +ArchUnit lo obliga. *Esfuerzo:* M. + +**QA-04 — La clasificación de errores del proveedor es privada e inalcanzable.** +`GoTrueClient.mapError` concentra doce decisiones tras un método privado; solo se llega por HTTP +simulado, que es la razón de que la clase estuviera al 13 % de ramas. *Criterio:* la traducción +(estado, cuerpo) a `UpstreamAuthError` se extrae a un componente probable directamente. +*Esfuerzo:* M. + +**QA-05 — `Instant.now()` y `UUID.randomUUID()` fuera del `Clock` inyectado.** +`GlobalExceptionHandler.java:90`, `SecurityConfig.java:110` y `TraceIdFilter.java:28`, pese a existir +`ClockConfig`. El `timestamp` y el `traceId` de una respuesta de error no son aseverables. +*Criterio:* las tres reciben `Clock` por constructor y una prueba con `Clock.fixed` asevera el +`timestamp` exacto. *Esfuerzo:* S. + +**QA-06 — ArchUnit no cubre el sentido `api → infrastructure` ni los ciclos.** +Solo existe la regla inversa. Nada impide que un controlador importe un `*Adapter` o un `*Entity`. +*Criterio:* se añaden `API_DOES_NOT_DEPEND_ON_INFRASTRUCTURE`, una `layeredArchitecture()` completa +y `slices().should().beFreeOfCycles()`. *Esfuerzo:* S. + +**QA-07 — Las reglas entre módulos están cableadas a `identity` y `auth`.** +Cuando entren `catalog` y `booking`, previstos en `componentes.md`, no habrá ninguna regla que los +cubra. *Criterio:* las reglas se reescriben de forma genérica para que cualquier módulo nuevo quede +protegido sin editar el test. *Esfuerzo:* M. + +**QA-08 — El catch-all del manejador de errores puede estar tapando los códigos de Spring MVC.** +`GlobalExceptionHandler` declara `@ExceptionHandler(Exception.class)` con `@Order(HIGHEST_PRECEDENCE)`. +Queda la sospecha de que un método no permitido, un tipo de medio no soportado o una ruta inexistente +se respondan como 500 en vez de 405, 415 y 404. *Criterio:* una prueba de integración que confirme +o descarte cada uno de los tres casos. *Esfuerzo:* S. diff --git a/pom.xml b/pom.xml index 262b290..640e0cf 100644 --- a/pom.xml +++ b/pom.xml @@ -21,8 +21,10 @@ 3.1.1 1.4.1 1.21.4 - 0.8.12 + 0.8.13 + 1.19.1 + 1.2.2 codefactorybookingapp CodeFactoryBookingApp_bookingplatform https://sonarcloud.io @@ -174,6 +176,93 @@ + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + **/BookingPlatformApplication.class + + + + + prepare-agent + + prepare-agent + + + + + report + verify + + report + + + + + check-coverage + verify + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.90 + + + BRANCH + COVEREDRATIO + 0.90 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin @@ -200,25 +289,92 @@ + - org.jacoco - jacoco-maven-plugin - ${jacoco.version} - - - prepare-agent - - prepare-agent - - - - report - verify - - report - - - + org.pitest + pitest-maven + ${pitest.version} + + + org.pitest + pitest-junit5-plugin + ${pitest.junit5.version} + + + + + com.codefactory.bookingplatform.* + + + com.codefactory.bookingplatform.* + + + + com.codefactory.bookingplatform.BookingPlatformApplication + com.codefactory.bookingplatform.*.api.dto.* + com.codefactory.bookingplatform.auth.domain.model.AppRole + com.codefactory.bookingplatform.auth.domain.model.AuthTokens + com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser + com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError + com.codefactory.bookingplatform.identity.domain.model.ClientStatus + com.codefactory.bookingplatform.identity.domain.model.NotificationChannel + com.codefactory.bookingplatform.identity.application.RegisterClientCommand + com.codefactory.bookingplatform.identity.application.RegistrationOutcome + com.codefactory.bookingplatform.shared.error.ErrorCode + com.codefactory.bookingplatform.shared.config.AuthPolicyProperties + com.codefactory.bookingplatform.shared.config.SecurityProperties + com.codefactory.bookingplatform.shared.config.SupabaseProperties + com.codefactory.bookingplatform.shared.config.OpenApiConfig + com.codefactory.bookingplatform.shared.config.ClockConfig + com.codefactory.bookingplatform.shared.persistence.JpaAuditingConfig + + + + com.codefactory.bookingplatform.*IT + + + ALL + + + HTML + XML + + false + 4 + 8000 + + 95 + diff --git a/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCase.java b/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCase.java index d93177b..2804aad 100644 --- a/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCase.java +++ b/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCase.java @@ -3,6 +3,8 @@ import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -30,7 +32,20 @@ public void requestRecovery(String email) { log.debug("Recovery requested for unknown email; ignored to avoid user enumeration"); return; } - throw ex; + throw mapUpstream(ex); } } + + /** + * Translates the upstream failure the same way {@code LoginUseCase} and + * {@code LogoutUseCase} do. Without it the raw {@link UpstreamAuthException} reached + * the generic handler and the caller got a 500: on a provider rate limit the answer + * changed from 202 to 500, which is itself an enumeration signal. + */ + private BusinessException mapUpstream(UpstreamAuthException ex) { + if (ex.error() == UpstreamAuthError.RATE_LIMITED) { + return new BusinessException(ErrorCode.RATE_LIMITED); + } + return new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR, ex.getMessage()); + } } diff --git a/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCase.java b/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCase.java index c2db21c..2f2684d 100644 --- a/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCase.java +++ b/src/main/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCase.java @@ -36,7 +36,18 @@ public void resetPassword(String tokenHash, String newPassword) { if (ex.error() == UpstreamAuthError.TOKEN_INVALID || ex.error() == UpstreamAuthError.TOKEN_EXPIRED) { throw new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID); } - throw ex; + throw mapUpstream(ex); } } + + /** + * Same translation as {@code LoginUseCase} and {@code LogoutUseCase}: a raw + * {@link UpstreamAuthException} has no handler and would reach the caller as a 500. + */ + private BusinessException mapUpstream(UpstreamAuthException ex) { + if (ex.error() == UpstreamAuthError.RATE_LIMITED) { + return new BusinessException(ErrorCode.RATE_LIMITED); + } + return new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR, ex.getMessage()); + } } diff --git a/src/main/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthException.java b/src/main/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthException.java index a541036..f902361 100644 --- a/src/main/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthException.java +++ b/src/main/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthException.java @@ -2,6 +2,8 @@ public class UpstreamAuthException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final UpstreamAuthError error; public UpstreamAuthException(UpstreamAuthError error, String message) { diff --git a/src/main/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClient.java b/src/main/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClient.java index 5e4585c..4ec301d 100644 --- a/src/main/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClient.java +++ b/src/main/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClient.java @@ -30,6 +30,9 @@ public class GoTrueClient implements IdentityProviderPort { private static final Logger log = LoggerFactory.getLogger(GoTrueClient.class); + /** Error-mapping context of the /verify endpoint, shared by email confirmation and password reset. */ + private static final String VERIFY_CONTEXT = "verify"; + private final RestClient restClient; private final SupabaseProperties properties; @@ -56,7 +59,7 @@ public UUID createUser(String email, String password, AppRole role) { .body(body) .retrieve() .body(Map.class); - return UUID.fromString(String.valueOf(requireField(response, "id"))); + return requireUuid(response, "id"); } catch (RestClientResponseException ex) { throw mapError(ex, "createUser"); } catch (ResourceAccessException ex) { @@ -94,7 +97,7 @@ public AuthTokens requestPasswordToken(String email, String password) { String.valueOf(requireField(response, "access_token")), String.valueOf(requireField(response, "refresh_token")), String.valueOf(response.getOrDefault("token_type", "bearer")), - Long.parseLong(String.valueOf(response.getOrDefault("expires_in", "3600")))); + requireExpiresIn(response)); } catch (RestClientResponseException ex) { throw mapError(ex, "token"); } catch (ResourceAccessException ex) { @@ -116,9 +119,12 @@ public ConfirmedUser verifyEmailToken(String tokenHash) { } else { user = response; } + // El correo no lo consume nadie: ConfirmEmailUseCase resuelve el cliente por + // userId. Exigirlo con requireField convertiria una respuesta sin ese campo en + // un 502 para un valor que se descarta, asi que se deja tolerante a proposito. return new ConfirmedUser( - UUID.fromString(String.valueOf(requireField(user, "id"))), - String.valueOf(user.get("email"))); + requireUuid(user, "id"), + user.get("email") == null ? null : String.valueOf(user.get("email"))); } @Override @@ -199,7 +205,7 @@ private Map verify(String tokenHash, String type, String passwor .body(Map.class); return response != null ? response : Map.of(); } catch (RestClientResponseException ex) { - throw mapError(ex, "verify"); + throw mapError(ex, VERIFY_CONTEXT); } catch (ResourceAccessException ex) { throw unavailable(ex); } @@ -222,6 +228,35 @@ private Object requireField(Map response, String field) { return response.get(field); } + /** + * A malformed id is an unusable provider answer, exactly like a missing one: + * the raw {@link IllegalArgumentException} from {@code UUID.fromString} would + * escape the adapter and surface as a 500 instead of an upstream error. + */ + private UUID requireUuid(Map response, String field) { + String raw = String.valueOf(requireField(response, field)); + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + throw new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, + "Unexpected identity provider response, field is not a valid UUID: " + field, ex); + } + } + + /** + * {@code expires_in} is optional (defaults to one hour), but a present value that + * is not a whole number — {@code "never"}, or the perfectly valid JSON number + * {@code 3600.0} — must not escape as a raw {@link NumberFormatException}. + */ + private long requireExpiresIn(Map response) { + try { + return Long.parseLong(String.valueOf(response.getOrDefault("expires_in", "3600"))); + } catch (NumberFormatException ex) { + throw new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, + "Unexpected identity provider response, field is not a number: expires_in", ex); + } + } + private UpstreamAuthException unavailable(Exception cause) { log.error("Identity provider unreachable: {}", cause.getMessage()); return new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, @@ -242,20 +277,25 @@ private UpstreamAuthException mapError(RestClientResponseException ex, String co if (body.contains("already exists") || body.contains("user_exists") || body.contains("email_exists")) { return new UpstreamAuthException(UpstreamAuthError.USER_ALREADY_EXISTS, "User already exists"); } - if (body.contains("not found") || status == 404) { - return new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "User not found"); - } + // GoTrue answers 404 to an expired or already consumed OTP, so "expired" has to + // be checked before the 404 rule: otherwise a stale verification link is reported + // as USER_NOT_FOUND and the user reads "usuario no encontrado". if (body.contains("expired")) { return new UpstreamAuthException(UpstreamAuthError.TOKEN_EXPIRED, "Token has expired"); } + // A bare 404 from /verify is about the one-time token, never about a user; it is + // classified below by the verify branch. Every other endpoint keeps the old rule. + if (body.contains("not found") || (status == 404 && !VERIFY_CONTEXT.equals(context))) { + return new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "User not found"); + } switch (context) { case "token": if (status == 400 || status == 401) { return new UpstreamAuthException(UpstreamAuthError.INVALID_CREDENTIALS, "Invalid login credentials"); } break; - case "verify": - if (status == 400 || status == 403) { + case VERIFY_CONTEXT: + if (status == 400 || status == 403 || status == 404) { return new UpstreamAuthException(UpstreamAuthError.TOKEN_INVALID, "Invalid or already used token"); } break; diff --git a/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java b/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java index c89ede1..0913fc3 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java @@ -26,6 +26,7 @@ import java.io.IOException; import java.net.URI; import java.time.Instant; +import java.util.Locale; @Configuration @EnableWebSecurity @@ -103,7 +104,8 @@ private void writeProblem(HttpServletResponse response, HttpServletRequest reque ErrorCode code, String message) throws IOException { ProblemDetail problem = ProblemDetail.forStatusAndDetail(code.status(), message); problem.setTitle(code.status().getReasonPhrase()); - problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase())); + // Locale.ROOT: same stable-identifier reason as in GlobalExceptionHandler. + problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase(Locale.ROOT))); problem.setInstance(URI.create(request.getRequestURI())); problem.setProperty("errorCode", code.name()); problem.setProperty("traceId", MDC.get("traceId")); diff --git a/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java b/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java index 1da32eb..42aed9b 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java @@ -9,6 +9,7 @@ import java.util.Collection; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -29,7 +30,10 @@ public Collection convert(Jwt jwt) { if (appMetadata instanceof Map metadata) { Object role = metadata.get(ROLE_KEY); if (role instanceof String roleValue && !roleValue.isBlank()) { - return List.of(new SimpleGrantedAuthority(ROLE_PREFIX + roleValue.toUpperCase())); + // Locale.ROOT: the authority name is a protocol value, not display text. + // Under a Turkish default locale "admin" would upper case to "ADMİN" + // and every hasRole("ADMIN") check would silently fail. + return List.of(new SimpleGrantedAuthority(ROLE_PREFIX + roleValue.toUpperCase(Locale.ROOT))); } } return List.of(); diff --git a/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java b/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java index 420c31a..e88c5d0 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java @@ -6,8 +6,18 @@ public class BusinessException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final ErrorCode errorCode; - private final Map details; + /** + * Declared as {@link LinkedHashMap} — not as {@code Map} — because the field is + * serialised with the exception: a plain {@code Map} is not a serialisable type, + * which is what {@code javac -Xlint:serial} reports. Marking it {@code transient} + * would silence the same warning but drop the details on deserialisation, so it is + * the option that changes behaviour. The published view stays unmodifiable, and the + * copy stays defensive; only the declared type changed. + */ + private final LinkedHashMap details; public BusinessException(ErrorCode errorCode) { this(errorCode, errorCode.defaultMessage(), Collections.emptyMap()); @@ -20,7 +30,7 @@ public BusinessException(ErrorCode errorCode, String message) { public BusinessException(ErrorCode errorCode, String message, Map details) { super(message); this.errorCode = errorCode; - this.details = Collections.unmodifiableMap(new LinkedHashMap<>(details)); + this.details = new LinkedHashMap<>(details); } public static BusinessException of(ErrorCode errorCode, String message, String detailKey, String detailValue) { @@ -32,6 +42,6 @@ public ErrorCode errorCode() { } public Map details() { - return details; + return Collections.unmodifiableMap(details); } } diff --git a/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java b/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java index 2eb0d41..b1da145 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java @@ -19,6 +19,7 @@ import java.net.URI; import java.time.Instant; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; @RestControllerAdvice @@ -83,7 +84,10 @@ public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) private ProblemDetail build(ErrorCode code, String message, HttpServletRequest request) { ProblemDetail problem = ProblemDetail.forStatusAndDetail(code.status(), message); problem.setTitle(code.status().is5xxServerError() ? HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase() : code.status().getReasonPhrase()); - problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase())); + // Locale.ROOT: the type URI is a stable identifier, not display text. + // Under a Turkish default locale VALIDATION_ERROR would lower case to + // "valıdatıon_error" and the published error type would stop matching. + problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase(Locale.ROOT))); problem.setInstance(URI.create(request.getRequestURI())); problem.setProperty("errorCode", code.name()); problem.setProperty(TRACE_ID_PROPERTY, MDC.get("traceId")); diff --git a/src/test/java/com/codefactory/bookingplatform/acceptance/HappyPathAcceptanceIT.java b/src/test/java/com/codefactory/bookingplatform/acceptance/HappyPathAcceptanceIT.java new file mode 100644 index 0000000..65315ad --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/acceptance/HappyPathAcceptanceIT.java @@ -0,0 +1,132 @@ +package com.codefactory.bookingplatform.acceptance; + +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientEntity; +import com.codefactory.bookingplatform.support.JwtIntegrationTestBase; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * HU-001 + HU-021 end to end, over HTTP and against PostgreSQL: the exact path shown in the demo. + * + *

Registration -> email confirmation -> login -> {@code /me} with the issued token -> + * logout. Every step asserts the HTTP status and the state of the client row, so if any of the two + * stories regresses this is the test that says where. + */ +class HappyPathAcceptanceIT extends JwtIntegrationTestBase { + + private static final String EMAIL = "ana.perez@example.com"; + private static final String DOCUMENT = "CC-1020304050"; + private static final String PASSWORD = "Str0ng!Pass"; + + @Test + @DisplayName("HU-001 + HU-021: register, confirm the email, log in, read /me and log out") + void fullHappyPath() throws Exception { + // 1. Registration: 201 and the client lands in the database as PENDING_VERIFICATION + String registrationBody = mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content(registrationPayload(EMAIL, DOCUMENT))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.email").value(EMAIL)) + .andExpect(jsonPath("$.status").value("PENDING_VERIFICATION")) + .andExpect(header().exists("X-Trace-Id")) + .andReturn().getResponse().getContentAsString(); + + UUID clientId = UUID.fromString(JsonPath.read(registrationBody, "$.clientId")); + assertEquals(identityProvider.userIdOf(EMAIL), clientId, + "the client id must be the auth user id assigned by the provider"); + ClientEntity stored = storedClient(clientId); + assertEquals(ClientStatus.PENDING_VERIFICATION, stored.getStatus()); + assertEquals(DOCUMENT, stored.getDocument()); + assertFalse(identityProvider.isEmailConfirmed(EMAIL)); + + // 2. Login before confirming: 403, the account is not usable yet + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginPayload())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value("EMAIL_NOT_CONFIRMED")); + assertEquals(ClientStatus.PENDING_VERIFICATION, storedClient(clientId).getStatus()); + + // 3. Email confirmation with the one-time token_hash: 200 and the row flips to ACTIVE + String tokenHash = identityProvider.currentEmailToken(EMAIL); + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\":\"" + tokenHash + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clientId").value(clientId.toString())) + .andExpect(jsonPath("$.status").value("ACTIVE")); + assertEquals(ClientStatus.ACTIVE, storedClient(clientId).getStatus()); + assertTrue(identityProvider.isEmailConfirmed(EMAIL)); + + // 4. Login: 200 with a real access token + String loginBody = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginPayload())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tokenType").value("bearer")) + .andExpect(jsonPath("$.expiresIn").value(900)) + .andExpect(jsonPath("$.refreshToken").isNotEmpty()) + .andReturn().getResponse().getContentAsString(); + String accessToken = JsonPath.read(loginBody, "$.accessToken"); + assertNotNull(accessToken); + + // 5. /me with that token: 200, and it goes through the production JwtDecoder + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(clientId.toString())) + .andExpect(jsonPath("$.email").value(EMAIL)) + .andExpect(jsonPath("$.role").value("CLIENT")); + + // 6. Logout: 204 and the session is revoked upstream + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + assertTrue(identityProvider.isSessionRevoked(accessToken)); + + // The client row is untouched by the session lifecycle + assertEquals(ClientStatus.ACTIVE, storedClient(clientId).getStatus()); + assertEquals(1, clientJpaRepository.count()); + } + + @Test + @DisplayName("HU-021: after a successful login the failed-attempt counter does not lock the account") + void successfulLoginDoesNotAccumulateLockState() throws Exception { + mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content(registrationPayload(EMAIL, DOCUMENT))) + .andExpect(status().isCreated()); + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\":\"" + identityProvider.currentEmailToken(EMAIL) + "\"}")) + .andExpect(status().isOk()); + + for (int attempt = 1; attempt <= 6; attempt++) { + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginPayload())) + .andExpect(status().isOk()); + } + } + + private ClientEntity storedClient(UUID clientId) { + return clientJpaRepository.findById(clientId).orElseThrow(); + } + + private static String loginPayload() { + return "{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"; + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/acceptance/OneTimeLinkIT.java b/src/test/java/com/codefactory/bookingplatform/acceptance/OneTimeLinkIT.java new file mode 100644 index 0000000..dffe9a6 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/acceptance/OneTimeLinkIT.java @@ -0,0 +1,192 @@ +package com.codefactory.bookingplatform.acceptance; + +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientEntity; +import com.codefactory.bookingplatform.support.JwtIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * HU-001 / HU-021 - acceptance criterion "enlace de un solo uso". + * + *

The existing ITs cover the expired link. What gives the criterion its name, though, + * is re-use: a link that already did its job must not do it a second time. These tests redeem a + * real {@code token_hash} and then replay it, both for the email-verification link (HU-001) and for + * the password-recovery link (HU-021), and check that nothing changes on the replay. + */ +class OneTimeLinkIT extends JwtIntegrationTestBase { + + private static final String EMAIL = "ana.perez@example.com"; + private static final String DOCUMENT = "CC-1020304050"; + + @Test + @DisplayName("HU-001 AC 'reenvío del correo de verificación con enlace vigente': the email link is single use") + void emailVerificationLinkIsRejectedOnSecondUse() throws Exception { + UUID clientId = register(); + String tokenHash = identityProvider.currentEmailToken(EMAIL); + + // Given the client clicks the link once + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content(confirmPayload(tokenHash))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clientId").value(clientId.toString())) + .andExpect(jsonPath("$.status").value("ACTIVE")); + + assertEquals(ClientStatus.ACTIVE, storedClient(clientId).getStatus()); + assertTrue(identityProvider.isTokenRedeemed(tokenHash), "the provider must have burned the token"); + + // When the very same token_hash is replayed (forwarded mail, browser history, attacker) + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content(confirmPayload(tokenHash))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VERIFICATION_TOKEN_INVALID")) + .andExpect(jsonPath("$.traceId").exists()); + + // Then the client keeps the state the first redemption left, no second transition + assertEquals(ClientStatus.ACTIVE, storedClient(clientId).getStatus()); + } + + @Test + @DisplayName("HU-001 AC: resending the verification email invalidates the previous link and issues a live one") + void resendingVerificationSupersedesThePreviousLink() throws Exception { + UUID clientId = register(); + String firstToken = identityProvider.currentEmailToken(EMAIL); + + mockMvc.perform(post("/api/v1/registrations/verification-resends") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\"}")) + .andExpect(status().isAccepted()); + + String secondToken = identityProvider.currentEmailToken(EMAIL); + assertNotEquals(firstToken, secondToken, "a resend must mint a new link"); + + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content(confirmPayload(firstToken))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VERIFICATION_TOKEN_INVALID")); + assertEquals(ClientStatus.PENDING_VERIFICATION, storedClient(clientId).getStatus()); + + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content(confirmPayload(secondToken))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ACTIVE")); + assertEquals(ClientStatus.ACTIVE, storedClient(clientId).getStatus()); + } + + @Test + @DisplayName("HU-021 AC 'recuperación de contraseña con enlace de un solo uso': the recovery link is single use") + void passwordRecoveryLinkIsRejectedOnSecondUse() throws Exception { + registerAndConfirm(); + + mockMvc.perform(post("/api/v1/auth/password-recovery-requests") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\"}")) + .andExpect(status().isAccepted()); + + String tokenHash = identityProvider.currentRecoveryToken(EMAIL); + + mockMvc.perform(post("/api/v1/auth/password-resets") + .contentType(MediaType.APPLICATION_JSON) + .content(resetPayload(tokenHash, "N3w!StrongPass"))) + .andExpect(status().isNoContent()); + assertTrue(identityProvider.isTokenRedeemed(tokenHash), "the provider must have burned the recovery token"); + + // Replaying the same link must not let anyone set the password again + mockMvc.perform(post("/api/v1/auth/password-resets") + .contentType(MediaType.APPLICATION_JSON) + .content(resetPayload(tokenHash, "An0ther!Pass"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VERIFICATION_TOKEN_INVALID")); + + // And the password that counts is the one set by the single valid redemption + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginPayload("An0ther!Pass"))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("INVALID_CREDENTIALS")); + + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginPayload("N3w!StrongPass"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").isNotEmpty()); + } + + @Test + @DisplayName("HU-021 AC: a recovery link rejected by the password policy is NOT consumed") + void weakPasswordDoesNotBurnTheRecoveryLink() throws Exception { + registerAndConfirm(); + mockMvc.perform(post("/api/v1/auth/password-recovery-requests") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\"}")) + .andExpect(status().isAccepted()); + String tokenHash = identityProvider.currentRecoveryToken(EMAIL); + + mockMvc.perform(post("/api/v1/auth/password-resets") + .contentType(MediaType.APPLICATION_JSON) + .content(resetPayload(tokenHash, "todolowercase"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("PASSWORD_TOO_WEAK")); + + assertFalse(identityProvider.isTokenRedeemed(tokenHash), + "a request stopped by the local policy must leave the link usable"); + + mockMvc.perform(post("/api/v1/auth/password-resets") + .contentType(MediaType.APPLICATION_JSON) + .content(resetPayload(tokenHash, "N3w!StrongPass"))) + .andExpect(status().isNoContent()); + } + + // --- helpers ------------------------------------------------------------ + + private UUID register() throws Exception { + mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content(registrationPayload(EMAIL, DOCUMENT))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("PENDING_VERIFICATION")); + UUID clientId = identityProvider.userIdOf(EMAIL); + assertNotNull(clientId, "the provider must have provisioned the auth user"); + return clientId; + } + + private void registerAndConfirm() throws Exception { + register(); + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content(confirmPayload(identityProvider.currentEmailToken(EMAIL)))) + .andExpect(status().isOk()); + } + + private ClientEntity storedClient(UUID clientId) { + return clientJpaRepository.findById(clientId).orElseThrow(); + } + + private static String confirmPayload(String tokenHash) { + return "{\"tokenHash\":\"" + tokenHash + "\"}"; + } + + private static String resetPayload(String tokenHash, String newPassword) { + return "{\"tokenHash\":\"" + tokenHash + "\",\"newPassword\":\"" + newPassword + "\"}"; + } + + private static String loginPayload(String password) { + return "{\"email\":\"" + EMAIL + "\",\"password\":\"" + password + "\"}"; + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/architecture/BookingConfirmationInvariantTest.java b/src/test/java/com/codefactory/bookingplatform/architecture/BookingConfirmationInvariantTest.java new file mode 100644 index 0000000..128117f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/architecture/BookingConfirmationInvariantTest.java @@ -0,0 +1,105 @@ +package com.codefactory.bookingplatform.architecture; + +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaFieldAccess; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * HU-001 - guard for the acceptance criterion "cliente no verificado no puede confirmar reserva". + * + *

Status today: the invariant lives in {@link Client#canConfirmBooking()} and is unit tested, + * but no production code calls it, because Sprint 1 has no booking flow. The + * criterion therefore cannot be verified through behaviour, and inventing a booking endpoint just + * to test it would be inventing the feature. + * + *

What this class does instead is fence the invariant so it cannot be bypassed later: + * + *

    + *
  1. the invariant must keep existing as a public method on the aggregate (anchor);
  2. + *
  3. {@code ClientStatus.ACTIVE} must not be read outside the identity domain model - that is + * the shape an ad-hoc reimplementation of the rule would take;
  4. + *
  5. the day a booking-confirmation path appears, it must consult the invariant.
  6. + *
+ * + * Rules 2 and 3 pass vacuously today and start biting the moment the booking module lands. + */ +class BookingConfirmationInvariantTest { + + private static final String ROOT_PACKAGE = "com.codefactory.bookingplatform"; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(new ImportOption.DoNotIncludeTests()) + .importPackages(ROOT_PACKAGE); + + /** Classes that would carry a booking-confirmation path, by package or by name. */ + private static Set bookingConfirmationPaths() { + return PRODUCTION_CLASSES.stream() + .filter(javaClass -> javaClass.getPackageName().startsWith(ROOT_PACKAGE + ".booking") + || javaClass.getSimpleName() + .matches("^(?!BookingPlatform).*Booking.*(UseCase|Service|Controller|Handler|Facade)$")) + .collect(Collectors.toSet()); + } + + @Test + @DisplayName("HU-001 anchor: the 'only a verified client may confirm bookings' invariant is a public rule of the aggregate") + void invariantIsPublicOnTheAggregate() throws NoSuchMethodException { + var method = Client.class.getMethod("canConfirmBooking"); + assertEquals(boolean.class, method.getReturnType()); + assertTrue(Modifier.isPublic(method.getModifiers()), + "the booking module has to be able to ask the aggregate, not re-derive the rule"); + } + + @Test + @DisplayName("HU-001 guard: no production code outside the identity domain model reads ClientStatus.ACTIVE") + void nobodyReimplementsTheInvariantByComparingStatus() { + List offenders = PRODUCTION_CLASSES.stream() + .flatMap(javaClass -> javaClass.getFieldAccessesFromSelf().stream()) + .filter(access -> access.getTargetOwner().isEquivalentTo(ClientStatus.class)) + .filter(access -> "ACTIVE".equals(access.getTarget().getName())) + .filter(access -> access.getAccessType() == JavaFieldAccess.AccessType.GET) + // the aggregate itself owns the rule, and the enum's own /$values() are noise + .filter(access -> !access.getOriginOwner().isEquivalentTo(Client.class)) + .filter(access -> !access.getOriginOwner().isEquivalentTo(ClientStatus.class)) + .map(JavaFieldAccess::getDescription) + .toList(); + + assertTrue(offenders.isEmpty(), + "Comparing the status by hand bypasses Client.canConfirmBooking() and lets the two " + + "definitions of 'verified' drift apart. Call the aggregate instead. Offenders: " + offenders); + } + + @Test + @DisplayName("HU-001 guard: any booking-confirmation path must consult Client.canConfirmBooking()") + void bookingConfirmationPathsConsultTheInvariant() { + Set paths = bookingConfirmationPaths(); + if (paths.isEmpty()) { + // Sprint 1 has no booking flow yet; the criterion stays unverifiable by behaviour and + // this guard stays armed for the sprint that adds it. + return; + } + + boolean invariantConsulted = paths.stream() + .flatMap(javaClass -> javaClass.getMethodCallsFromSelf().stream()) + .anyMatch(call -> call.getTargetOwner().isEquivalentTo(Client.class) + && "canConfirmBooking".equals(call.getName())); + + assertTrue(invariantConsulted, + "HU-001 says only a verified client may confirm a booking, but none of these classes asks " + + "Client.canConfirmBooking(): " + + paths.stream().map(JavaClass::getName).sorted().toList()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/JwtValidationIT.java b/src/test/java/com/codefactory/bookingplatform/auth/JwtValidationIT.java new file mode 100644 index 0000000..7c8486d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/JwtValidationIT.java @@ -0,0 +1,98 @@ +package com.codefactory.bookingplatform.auth; + +import com.codefactory.bookingplatform.support.JwtIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * HU-021 - "inicio de sesión exitoso según rol", exercised through the real token pipeline. + * + *

Every other test in the suite authenticates with + * {@code SecurityMockMvcRequestPostProcessors.jwt()}, which injects an already-built + * {@code JwtAuthenticationToken} and therefore never touches {@code SecurityConfig.jwtDecoder()}. + * A wrong JWKS URI, a missing algorithm or a missing issuer check would sail through the whole + * suite and only show up in production. These tests send a raw {@code Authorization: Bearer} + * header so the request goes through {@code BearerTokenAuthenticationFilter} -> + * {@code NimbusJwtDecoder} -> JWKS fetch -> signature, {@code exp} and {@code iss} validation. + * + * @see com.codefactory.bookingplatform.shared.config.SecurityConfig#jwtDecoder() + */ +class JwtValidationIT extends JwtIntegrationTestBase { + + private static final UUID USER_ID = UUID.fromString("9f1c2f3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"); + private static final String EMAIL = "ana.perez@example.com"; + + @Test + @DisplayName("HU-021 AC: a well formed token is accepted by the production JwtDecoder and carries the role") + void wellFormedTokenIsAccepted() throws Exception { + String token = jwks().accessToken(USER_ID, EMAIL, "CLIENT"); + + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(USER_ID.toString())) + .andExpect(jsonPath("$.email").value(EMAIL)) + // app_metadata.role wins; the ADMIN planted in user_metadata must be ignored + .andExpect(jsonPath("$.role").value("CLIENT")); + + assertTrue(JWKS_SERVER.requestCount() > 0, + "the decoder must have fetched the JWK set over HTTP; if this is 0 the decoder was bypassed"); + } + + @Test + @DisplayName("HU-021 AC: a token minted by another issuer is rejected with 401 (JwtIssuerValidator)") + void tokenFromAnotherIssuerIsRejected() throws Exception { + String token = jwks().tokenFromAnotherIssuer(USER_ID, EMAIL, "CLIENT"); + + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("HU-021 AC: an expired token is rejected with 401 (JwtTimestampValidator)") + void expiredTokenIsRejected() throws Exception { + String token = jwks().expiredAccessToken(USER_ID, EMAIL, "CLIENT"); + + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("HU-021 AC: a token signed with a key outside the JWK set is rejected with 401") + void forgedSignatureIsRejected() throws Exception { + String token = jwks().tokenWithForgedSignature(USER_ID, EMAIL, "CLIENT"); + + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("HU-021 AC: a malformed bearer value is rejected with 401 and never reaches the controller") + void malformedTokenIsRejected() throws Exception { + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer not-a-jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("HU-021 AC: a valid token without app_metadata.role authenticates but carries no role") + void tokenWithoutRoleAuthenticatesWithoutAuthority() throws Exception { + String token = jwks().accessToken(USER_ID, EMAIL, ""); + + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").isEmpty()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/SessionInvalidationIT.java b/src/test/java/com/codefactory/bookingplatform/auth/SessionInvalidationIT.java new file mode 100644 index 0000000..daee298 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/SessionInvalidationIT.java @@ -0,0 +1,115 @@ +package com.codefactory.bookingplatform.auth; + +import com.codefactory.bookingplatform.support.JwtIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * HU-021 - acceptance criterion "cierre de sesión invalida la sesión". + * + *

{@code AuthFlowIT.logoutRevokesSession} only checks that {@code signOut} is invoked + * with the bearer value. That is the call, not the effect. These tests log in for real, use the + * token the provider handed out, log out, and then try the same token again. + */ +class SessionInvalidationIT extends JwtIntegrationTestBase { + + private static final String EMAIL = "ana.perez@example.com"; + private static final String DOCUMENT = "CC-1020304050"; + private static final String PASSWORD = "Str0ng!Pass"; + + @Test + @DisplayName("HU-021 AC 'cierre de sesión invalida la sesión': logout revokes the session at the provider") + void logoutRevokesTheIssuedSession() throws Exception { + String accessToken = loginAndGetAccessToken(); + + mockMvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value(EMAIL)); + + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + + assertTrue(identityProvider.isSessionRevoked(accessToken), + "logout must revoke at the provider the exact token the client was holding"); + } + + /** + * DEFECT D8 (characterization test). + * + *

The criterion says the session must stop working after logout. It does not: the access + * token is a self-contained JWT validated offline against the JWKS, and nothing in the request + * path asks the provider whether that session is still alive, nor is there a local deny list. + * Revoking at Supabase only kills the refresh token, so the access token keeps opening every + * protected endpoint until its own {@code exp} (900 s per {@code LoginResponse.expiresIn}). + * + *

ADR-0003 accepts this as a trade-off ("un access token robado sigue siendo válido hasta su + * expiración corta"), but the HU-021 criterion is written in absolute terms, so as far as + * traceability goes the criterion is only partially met: revocation reaches the provider, the + * session does not actually close. + * + *

This test pins the behaviour that exists today. It is deliberately written to fail the + * moment someone closes the gap - at which point the expectation below becomes 401 and the + * criterion is finally met. + */ + @Test + @DisplayName("HU-021 DEFECT D8: after logout the access token is still accepted (no revocation check)") + void accessTokenSurvivesLogout() throws Exception { + String accessToken = loginAndGetAccessToken(); + + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + + int statusAfterLogout = mockMvc.perform(get("/api/v1/auth/me") + .header("Authorization", "Bearer " + accessToken)) + .andReturn().getResponse().getStatus(); + + assertEquals(200, statusAfterLogout, + "Current behaviour pinned: HU-021 asks for 401 here. If this now returns 401 the defect " + + "was fixed - flip the expectation and move this test out of the DEFECT group."); + } + + @Test + @DisplayName("HU-021 AC: logging out twice is idempotent and never leaks an upstream error") + void logoutIsIdempotent() throws Exception { + String accessToken = loginAndGetAccessToken(); + + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("HU-021 AC: logout without a bearer token is rejected with 401") + void logoutRequiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + private String loginAndGetAccessToken() throws Exception { + mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content(registrationPayload(EMAIL, DOCUMENT))) + .andExpect(status().isCreated()); + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\":\"" + identityProvider.currentEmailToken(EMAIL) + "\"}")) + .andExpect(status().isOk()); + + String body = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return com.jayway.jsonpath.JsonPath.read(body, "$.accessToken"); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerTest.java b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerTest.java new file mode 100644 index 0000000..11ce34a --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerTest.java @@ -0,0 +1,129 @@ +package com.codefactory.bookingplatform.auth.api; + +import com.codefactory.bookingplatform.auth.api.dto.LoginRequest; +import com.codefactory.bookingplatform.auth.api.dto.LoginResponse; +import com.codefactory.bookingplatform.auth.api.dto.MeResponse; +import com.codefactory.bookingplatform.auth.api.dto.PasswordRecoveryRequest; +import com.codefactory.bookingplatform.auth.api.dto.PasswordResetRequest; +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The controller is a delegating layer: every endpoint must reach its own use case with the values + * the caller sent, and nothing else. These are plain unit tests without an application context, so + * each endpoint is exercised on its own instead of relying on a single cached Spring context to + * cover the whole class. + */ +class AuthControllerTest { + + private static final UUID SUBJECT = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + private final LoginUseCase loginUseCase = mock(LoginUseCase.class); + private final LogoutUseCase logoutUseCase = mock(LogoutUseCase.class); + private final PasswordRecoveryUseCase passwordRecoveryUseCase = mock(PasswordRecoveryUseCase.class); + private final PasswordResetUseCase passwordResetUseCase = mock(PasswordResetUseCase.class); + + private final AuthController controller = new AuthController( + loginUseCase, logoutUseCase, passwordRecoveryUseCase, passwordResetUseCase); + + private JwtAuthenticationToken authentication(String tokenValue, String... authorities) { + Jwt jwt = Jwt.withTokenValue(tokenValue) + .header("alg", "ES256") + .subject(SUBJECT.toString()) + .claim("email", "ana.perez@example.com") + .issuedAt(Instant.now().minusSeconds(60)) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + return new JwtAuthenticationToken(jwt, + List.of(authorities).stream().map(SimpleGrantedAuthority::new).map(a -> (org.springframework.security.core.GrantedAuthority) a).toList()); + } + + @Test + @DisplayName("Login forwards the submitted credentials to the login use case") + void loginForwardsCredentials() { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access", "refresh", "bearer", 3600L)); + + controller.login(new LoginRequest("ana.perez@example.com", "Str0ng!Pass")); + + verify(loginUseCase).login("ana.perez@example.com", "Str0ng!Pass"); + } + + @Test + @DisplayName("Login answers with the tokens the provider issued, not with a placeholder") + void loginReturnsTheIssuedTokens() { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access", "refresh", "bearer", 3600L)); + + LoginResponse response = controller.login(new LoginRequest("ana.perez@example.com", "Str0ng!Pass")); + + assertEquals("access", response.accessToken()); + assertEquals("refresh", response.refreshToken()); + assertEquals("bearer", response.tokenType()); + assertEquals(3600L, response.expiresIn()); + } + + @Test + @DisplayName("Logout invalidates the raw bearer token of the caller, not the subject id") + void logoutForwardsTheBearerToken() { + controller.logout(authentication("the-access-token")); + + verify(logoutUseCase).logout("the-access-token"); + } + + @Test + @DisplayName("A password recovery request reaches the recovery use case with the submitted email") + void recoveryForwardsTheEmail() { + controller.requestPasswordRecovery(new PasswordRecoveryRequest("ana.perez@example.com")); + + verify(passwordRecoveryUseCase).requestRecovery("ana.perez@example.com"); + } + + @Test + @DisplayName("A password reset forwards both the one-time token hash and the new password") + void resetForwardsTokenAndPassword() { + controller.resetPassword(new PasswordResetRequest("token-hash", "NewSecret123!")); + + verify(passwordResetUseCase).resetPassword("token-hash", "NewSecret123!"); + } + + @Test + @DisplayName("The profile endpoint reads id and email from the token claims") + void meReadsTheTokenClaims() { + MeResponse response = controller.me(authentication("token", "ROLE_CLIENT")); + + assertEquals(SUBJECT, response.id()); + assertEquals("ana.perez@example.com", response.email()); + } + + @Test + @DisplayName("The profile endpoint publishes the role without its ROLE_ prefix") + void meStripsTheRolePrefix() { + assertEquals("CLIENT", controller.me(authentication("token", "ROLE_CLIENT")).role()); + } + + @Test + @DisplayName("A token that grants no ROLE_ authority yields no role at all") + void meReportsNoRoleWhenNoneIsGranted() { + assertNull(controller.me(authentication("token", "SCOPE_openid")).role()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerWebTest.java b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerWebTest.java new file mode 100644 index 0000000..6e09e77 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerWebTest.java @@ -0,0 +1,444 @@ +package com.codefactory.bookingplatform.auth.api; + +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web slice for {@link AuthController}: HTTP contract of every endpoint with the + * use cases mocked. Security filters are off; the authenticated endpoints get + * their {@link JwtAuthenticationToken} injected as the request principal, which + * is exactly what the argument resolver reads at runtime. + */ +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +class AuthControllerWebTest { + + private static final String SUBJECT = "11111111-2222-3333-4444-555555555555"; + private static final String LOGIN = "/api/v1/auth/login"; + private static final String LOGOUT = "/api/v1/auth/logout"; + private static final String RECOVERY = "/api/v1/auth/password-recovery-requests"; + private static final String RESETS = "/api/v1/auth/password-resets"; + private static final String ME = "/api/v1/auth/me"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LoginUseCase loginUseCase; + + @MockitoBean + private LogoutUseCase logoutUseCase; + + @MockitoBean + private PasswordRecoveryUseCase passwordRecoveryUseCase; + + @MockitoBean + private PasswordResetUseCase passwordResetUseCase; + + private static Jwt jwt(String tokenValue, String email) { + return Jwt.withTokenValue(tokenValue) + .header("alg", "ES256") + .subject(SUBJECT) + .claim("email", email) + .issuedAt(Instant.parse("2026-09-22T10:00:00Z")) + .expiresAt(Instant.parse("2026-09-22T11:00:00Z")) + .build(); + } + + private static JwtAuthenticationToken principal(String tokenValue, String... authorities) { + List granted = java.util.Arrays.stream(authorities) + .map(a -> (GrantedAuthority) new SimpleGrantedAuthority(a)) + .toList(); + return new JwtAuthenticationToken(jwt(tokenValue, "ana.perez@example.com"), granted); + } + + private static String loginPayload() { + return "{\"email\": \"ana.perez@example.com\", \"password\": \"Str0ng!Pass\"}"; + } + + // ------------------------------------------------------------------ login + + @Test + @DisplayName("POST /login answers 200 OK") + void loginAnswers200() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /login returns accessToken, refreshToken, tokenType and expiresIn") + void loginReturnsTheTokenBundle() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.accessToken").value("access-token")) + .andExpect(jsonPath("$.refreshToken").value("refresh-token")) + .andExpect(jsonPath("$.tokenType").value("bearer")) + .andExpect(jsonPath("$.expiresIn").value(3600)); + } + + @Test + @DisplayName("SECURITY: the login response never echoes the submitted password") + void loginResponseDoesNotLeakThePassword() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + String body = mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("Str0ng!Pass"), "the plain password leaked into the login response"); + assertFalse(body.toLowerCase().contains("password"), "a password field leaked into the login response"); + } + + @Test + @DisplayName("POST /login forwards the credentials to the use case verbatim") + void loginForwardsTheCredentials() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isOk()); + + verify(loginUseCase).login("ana.perez@example.com", "Str0ng!Pass"); + } + + @ParameterizedTest(name = "{0} from the login use case becomes HTTP {1}") + @CsvSource({ + "INVALID_CREDENTIALS, 401", + "EMAIL_NOT_CONFIRMED, 403", + "ACCOUNT_LOCKED, 429", + "UPSTREAM_AUTH_ERROR, 502", + "RATE_LIMITED, 429", + "AUTH_TOKEN_INVALID, 401"}) + @DisplayName("Login business failures are mapped to their declared status and errorCode") + void loginFailuresAreMapped(String errorCode, int expectedStatus) throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenThrow(new BusinessException(ErrorCode.valueOf(errorCode))); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().is(expectedStatus)) + .andExpect(jsonPath("$.errorCode").value(errorCode)); + } + + @Test + @DisplayName("A locked account answers 429 and tells the caller how long to wait") + void lockedAccountExposesRetryAfterMinutes() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenThrow(new BusinessException(ErrorCode.ACCOUNT_LOCKED, + ErrorCode.ACCOUNT_LOCKED.defaultMessage(), Map.of("retryAfterMinutes", "15"))); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.errorCode").value("ACCOUNT_LOCKED")) + .andExpect(jsonPath("$.details.retryAfterMinutes").value("15")); + } + + @Test + @DisplayName("SECURITY: a rejected login does not reveal whether the email exists") + void invalidCredentialsMessageIsNeutral() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.detail").value("Invalid email or password")); + } + + @ParameterizedTest(name = "login with email [{0}] is rejected with 400") + @ValueSource(strings = {"", " ", "not-an-email", "@example.com"}) + void loginRejectsInvalidEmails(String email) throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"" + email + "\", \"password\": \"Str0ng!Pass\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.details.email").isNotEmpty()); + + verify(loginUseCase, never()).login(anyString(), anyString()); + } + + @ParameterizedTest(name = "login with password [{0}] is rejected with 400") + @ValueSource(strings = {"", " "}) + void loginRejectsBlankPasswords(String password) throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\", \"password\": \"" + password + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.password").value("password is required")); + + verify(loginUseCase, never()).login(anyString(), anyString()); + } + + @Test + @DisplayName("A missing password field is rejected with 400") + void loginRejectsMissingPassword() throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.password").value("password is required")); + } + + @Test + @DisplayName("A syntactically broken login body is answered 400 VALIDATION_ERROR") + void loginRejectsBrokenJson() throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana@example.com\"")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.detail").value("Malformed request body")); + } + + // ----------------------------------------------------------------- logout + + @Test + @DisplayName("POST /logout answers 204 No Content with an empty body") + void logoutAnswers204() throws Exception { + mockMvc.perform(post(LOGOUT).principal(principal("the-access-token", "ROLE_CLIENT"))) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + } + + @Test + @DisplayName("POST /logout hands the raw bearer token to the use case") + void logoutForwardsTheRawToken() throws Exception { + mockMvc.perform(post(LOGOUT).principal(principal("the-access-token", "ROLE_CLIENT"))) + .andExpect(status().isNoContent()); + + verify(logoutUseCase).logout("the-access-token"); + } + + @Test + @DisplayName("A provider failure during logout surfaces as 502 UPSTREAM_AUTH_ERROR") + void logoutUpstreamFailureIsMappedTo502() throws Exception { + doThrow(new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR)) + .when(logoutUseCase).logout(anyString()); + + mockMvc.perform(post(LOGOUT).principal(principal("the-access-token", "ROLE_CLIENT"))) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.errorCode").value("UPSTREAM_AUTH_ERROR")); + } + + // --------------------------------------------------------- recovery/reset + + @Test + @DisplayName("POST /password-recovery-requests answers 202 Accepted with an empty body") + void recoveryAnswers202() throws Exception { + mockMvc.perform(post(RECOVERY) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isAccepted()) + .andExpect(content().string("")); + + verify(passwordRecoveryUseCase).requestRecovery("ana.perez@example.com"); + } + + @Test + @DisplayName("SECURITY: an unknown email also gets 202, so the endpoint does not enumerate users") + void recoveryDoesNotEnumerateUsers() throws Exception { + mockMvc.perform(post(RECOVERY) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"nobody@example.com\"}")) + .andExpect(status().isAccepted()); + } + + @ParameterizedTest(name = "recovery with email [{0}] is rejected with 400") + @ValueSource(strings = {"", " ", "not-an-email", "@example.com"}) + void recoveryRejectsInvalidEmails(String email) throws Exception { + mockMvc.perform(post(RECOVERY) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"" + email + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + + verify(passwordRecoveryUseCase, never()).requestRecovery(anyString()); + } + + @Test + @DisplayName("POST /password-resets answers 204 No Content with an empty body") + void resetAnswers204() throws Exception { + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"N3w!Password\"}")) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + verify(passwordResetUseCase).resetPassword("pkce_1a2b3c", "N3w!Password"); + } + + @Test + @DisplayName("A weak new password is answered 400 PASSWORD_TOO_WEAK with the violations") + void resetWithWeakPasswordIsRejected() throws Exception { + doThrow(new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, + ErrorCode.PASSWORD_TOO_WEAK.defaultMessage(), Map.of("violations", "at least one digit"))) + .when(passwordResetUseCase).resetPassword(anyString(), anyString()); + + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"onlyletters\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("PASSWORD_TOO_WEAK")) + .andExpect(jsonPath("$.details.violations").value("at least one digit")); + } + + @Test + @DisplayName("A consumed recovery token is answered 400 VERIFICATION_TOKEN_INVALID") + void resetWithConsumedTokenIsRejected() throws Exception { + doThrow(new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID)) + .when(passwordResetUseCase).resetPassword(anyString(), anyString()); + + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"already-used\", \"newPassword\": \"N3w!Password\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VERIFICATION_TOKEN_INVALID")); + } + + @ParameterizedTest(name = "reset with a {0} character password is rejected with 400") + @ValueSource(ints = {1, 7, 73}) + void resetRejectsPasswordsOutsideTheSizeRange(int length) throws Exception { + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"" + "a".repeat(length) + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.newPassword") + .value("newPassword must be between 8 and 72 characters")); + + verify(passwordResetUseCase, never()).resetPassword(anyString(), anyString()); + } + + @ParameterizedTest(name = "reset with tokenHash [{0}] is rejected with 400") + @ValueSource(strings = {"", " "}) + void resetRejectsBlankTokenHash(String tokenHash) throws Exception { + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"" + tokenHash + "\", \"newPassword\": \"N3w!Password\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.tokenHash").value("tokenHash is required")); + + verify(passwordResetUseCase, never()).resetPassword(anyString(), anyString()); + } + + @Test + @DisplayName("SECURITY: the reset answer carries no body at all, so no token echo is possible") + void resetAnswerCarriesNoToken() throws Exception { + String body = mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"N3w!Password\"}")) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("pkce_1a2b3c"), "the one-time token hash leaked into the reset response"); + } + + // --------------------------------------------------------------------- me + + @Test + @DisplayName("GET /me answers 200 with id, email and the role taken from the granted authority") + void meReturnsTheProfile() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "ROLE_CLIENT"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(SUBJECT)) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.role").value("CLIENT")); + } + + @Test + @DisplayName("GET /me strips the ROLE_ prefix from the authority") + void meStripsTheRolePrefix() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "ROLE_ADMIN"))) + .andExpect(jsonPath("$.role").value("ADMIN")); + } + + @Test + @DisplayName("GET /me ignores authorities that are not roles and reports role null") + void meIgnoresNonRoleAuthorities() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "SCOPE_read", "SCOPE_write"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").doesNotExist()); + } + + @Test + @DisplayName("GET /me reports role null when the token grants no authority at all") + void meReportsNullRoleWithoutAuthorities() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(SUBJECT)) + .andExpect(jsonPath("$.role").doesNotExist()); + } + + @Test + @DisplayName("GET /me picks the first ROLE_ authority when several are present") + void mePicksTheFirstRoleAuthority() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "SCOPE_read", "ROLE_PROVIDER", "ROLE_CLIENT"))) + .andExpect(jsonPath("$.role").value("PROVIDER")); + } + + @Test + @DisplayName("SECURITY: GET /me never returns the raw access token") + void meDoesNotLeakTheAccessToken() throws Exception { + String body = mockMvc.perform(get(ME).principal(principal("super-secret-access-token", "ROLE_CLIENT"))) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("super-secret-access-token"), "the access token leaked into the /me response"); + } + + @Test + @DisplayName("A subject that is not a UUID makes /me fail as a generic 500, not a leak") + void meWithNonUuidSubjectFailsSafely() throws Exception { + Jwt malformed = Jwt.withTokenValue("access-token") + .header("alg", "ES256") + .subject("not-a-uuid") + .claim("email", "ana.perez@example.com") + .issuedAt(Instant.parse("2026-09-22T10:00:00Z")) + .expiresAt(Instant.parse("2026-09-22T11:00:00Z")) + .build(); + JwtAuthenticationToken token = + new JwtAuthenticationToken(malformed, List.of(new SimpleGrantedAuthority("ROLE_CLIENT"))); + + mockMvc.perform(get(ME).principal(token)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.errorCode").value("INTERNAL_ERROR")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/api/dto/AuthRequestValidationTest.java b/src/test/java/com/codefactory/bookingplatform/auth/api/dto/AuthRequestValidationTest.java new file mode 100644 index 0000000..628127d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/api/dto/AuthRequestValidationTest.java @@ -0,0 +1,136 @@ +package com.codefactory.bookingplatform.auth.api.dto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static com.codefactory.bookingplatform.support.BeanValidationSupport.invalidProperties; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.messagesFor; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.repeat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Boundary and equivalence-class coverage for the auth module request DTOs, + * checked directly with a Jakarta Validator. + */ +class AuthRequestValidationTest { + + @Nested + @DisplayName("LoginRequest") + class Login { + + @Test + @DisplayName("A well formed login payload raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new LoginRequest("ana@example.com", "Str0ng!Pass"))); + } + + @ParameterizedTest(name = "missing, empty or blank email is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void emailIsMandatory(String value) { + assertTrue(messagesFor(new LoginRequest(value, "Str0ng!Pass"), "email") + .contains("email is required")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "a@", "@example.com", "ana example.com"}) + void emailFormatIsChecked(String value) { + assertTrue(messagesFor(new LoginRequest(value, "Str0ng!Pass"), "email") + .contains("email must be a valid address")); + } + + @ParameterizedTest(name = "missing, empty or blank password is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void passwordIsMandatory(String value) { + assertEquals(Set.of("password is required"), + messagesFor(new LoginRequest("ana@example.com", value), "password")); + } + + @Test + @DisplayName("Login does not constrain the password length: a single character is accepted") + void passwordHasNoLengthConstraint() { + assertTrue(messagesFor(new LoginRequest("ana@example.com", "x"), "password").isEmpty()); + } + } + + @Nested + @DisplayName("PasswordRecoveryRequest") + class Recovery { + + @Test + @DisplayName("A well formed recovery payload raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new PasswordRecoveryRequest("ana@example.com"))); + } + + @ParameterizedTest(name = "missing, empty or blank email is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void emailIsMandatory(String value) { + assertTrue(messagesFor(new PasswordRecoveryRequest(value), "email").contains("email is required")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "a@", "@example.com"}) + void emailFormatIsChecked(String value) { + assertTrue(messagesFor(new PasswordRecoveryRequest(value), "email") + .contains("email must be a valid address")); + } + } + + @Nested + @DisplayName("PasswordResetRequest") + class Reset { + + @Test + @DisplayName("A well formed reset payload raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new PasswordResetRequest("token-hash", "Str0ng!Pass"))); + } + + @ParameterizedTest(name = "missing, empty or blank tokenHash is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void tokenHashIsMandatory(String value) { + assertEquals(Set.of("tokenHash is required"), + messagesFor(new PasswordResetRequest(value, "Str0ng!Pass"), "tokenHash")); + } + + @ParameterizedTest(name = "newPassword of length {0} is accepted") + @ValueSource(ints = {8, 9, 71, 72}) + void acceptedPasswordLengths(int length) { + assertTrue(messagesFor(new PasswordResetRequest("token-hash", repeat('a', length)), "newPassword") + .isEmpty()); + } + + @ParameterizedTest(name = "newPassword of length {0} is outside [8, 72] and is rejected") + @ValueSource(ints = {1, 7, 73, 100}) + void rejectedPasswordLengths(int length) { + assertTrue(messagesFor(new PasswordResetRequest("token-hash", repeat('a', length)), "newPassword") + .contains("newPassword must be between 8 and 72 characters")); + } + + @ParameterizedTest(name = "missing or empty newPassword is rejected: [{0}]") + @NullSource + @ValueSource(strings = {""}) + void newPasswordIsMandatory(String value) { + assertTrue(messagesFor(new PasswordResetRequest("token-hash", value), "newPassword") + .contains("newPassword is required")); + } + + @Test + @DisplayName("A newPassword of eight spaces is long enough but still blank") + void blankNewPasswordIsRejected() { + assertEquals(Set.of("newPassword is required"), + messagesFor(new PasswordResetRequest("token-hash", " "), "newPassword")); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/LoginUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/LoginUseCaseTest.java new file mode 100644 index 0000000..2853daf --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/LoginUseCaseTest.java @@ -0,0 +1,413 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.auth.domain.port.LoginAttemptRepository; +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the login business rules (HU-021). + * + * Techniques applied, declared per group: + * - Boundary value analysis on the failed-attempt lock threshold. + * - Equivalence partitioning and decision tables on which upstream errors count + * as a failed attempt, and on the upstream-error to ErrorCode mapping. + * - Branch and condition coverage: both operands of the compound OR in the catch + * block, every arm of the mapping switch, both sides of the lock guard. + * - Interaction verification: the provider must not be reached while locked, and + * the attempt must be recorded with the normalized email. + */ +class LoginUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-09-22T10:00:00Z"); + private static final int MAX_FAILED_ATTEMPTS = 3; + private static final Duration LOCK_WINDOW = Duration.ofMinutes(15); + + private IdentityProviderPort identityProvider; + private LoginAttemptRepository loginAttemptRepository; + private LoginUseCase useCase; + + private final AuthTokens tokens = new AuthTokens("access", "refresh", "bearer", 3600L); + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + loginAttemptRepository = mock(LoginAttemptRepository.class); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + useCase = new LoginUseCase(identityProvider, loginAttemptRepository, + new LoginLockPolicy(MAX_FAILED_ATTEMPTS, LOCK_WINDOW), clock); + } + + /** Recent failures inside the sliding window, one per minute before now. */ + private void givenRecentFailures(int count) { + List failures = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + failures.add(NOW.minus(Duration.ofMinutes(i))); + } + when(loginAttemptRepository.findFailuresSince(anyString(), any(Instant.class))).thenReturn(failures); + } + + private UpstreamAuthException upstream(UpstreamAuthError error) { + return new UpstreamAuthException(error, "gotrue said " + error); + } + + // ----------------------------------------------------------------- // + // Black box: boundary value analysis, maxFailedAttempts -1 / = / +1 // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Lock threshold (boundary value analysis around maxFailedAttempts = 3)") + class LockThreshold { + + @Test + @DisplayName("One failure below the threshold still lets the user log in") + void oneBelowThresholdIsNotLocked() { + givenRecentFailures(MAX_FAILED_ATTEMPTS - 1); + when(identityProvider.requestPasswordToken("ana@example.com", "pwd")).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + + @Test + @DisplayName("Exactly maxFailedAttempts recent failures lock the account") + void exactlyAtThresholdIsLocked() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.ACCOUNT_LOCKED, ex.errorCode()); + } + + @Test + @DisplayName("One failure above the threshold keeps the account locked") + void oneAboveThresholdIsLocked() { + givenRecentFailures(MAX_FAILED_ATTEMPTS + 1); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.ACCOUNT_LOCKED, ex.errorCode()); + } + + @Test + @DisplayName("An account with no previous failures is never locked") + void noFailuresIsNotLocked() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + + @Test + @DisplayName("Failures older than the lock window do not lock the account") + void staleFailuresOutsideWindowDoNotLock() { + when(loginAttemptRepository.findFailuresSince(anyString(), any(Instant.class))) + .thenReturn(List.of(NOW.minus(Duration.ofMinutes(16)), + NOW.minus(Duration.ofMinutes(17)), + NOW.minus(Duration.ofMinutes(18)))); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + } + + // ----------------------------------------------------------------- // + // White box: the locked branch, plus interaction verification // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Lock response contract") + class LockResponse { + + @Test + @DisplayName("A locked account never reaches the identity provider") + void lockedAccountDoesNotCallProvider() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verifyNoInteractions(identityProvider); + } + + @Test + @DisplayName("A login rejected by the lock is not recorded as a new attempt") + void lockedAccountDoesNotRecordAttempt() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verify(loginAttemptRepository, never()).recordAttempt(anyString(), anyBoolean(), any(Instant.class)); + } + + @Test + @DisplayName("The lock reports retryAfterMinutes as the configured window in minutes") + void lockReportsRetryAfterMinutes() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(String.valueOf(LOCK_WINDOW.toMinutes()), ex.details().get("retryAfterMinutes")); + } + + @Test + @DisplayName("The lock is reported with the ACCOUNT_LOCKED default message") + void lockUsesDefaultMessage() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.ACCOUNT_LOCKED.defaultMessage(), ex.getMessage()); + } + } + + // ----------------------------------------------------------------- // + // Black box: equivalence partitions on email casing // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Email normalization (partitions: upper case, mixed case, already lower case)") + class EmailNormalization { + + @ParameterizedTest(name = "[{0}] is queried as [{1}]") + @CsvSource({ + "ANA@EXAMPLE.COM, ana@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com", + "ana@example.com, ana@example.com" + }) + @DisplayName("The email is lowercased before looking up the recent failures") + void emailIsNormalizedBeforeQuery(String rawEmail, String expected) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login(rawEmail, "pwd"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(loginAttemptRepository).findFailuresSince(captor.capture(), any(Instant.class)); + assertEquals(expected, captor.getValue()); + } + + @ParameterizedTest(name = "[{0}] is recorded as [{1}]") + @CsvSource({ + "ANA@EXAMPLE.COM, ana@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com" + }) + @DisplayName("The email is lowercased before recording a successful attempt") + void emailIsNormalizedBeforeRecordingSuccess(String rawEmail, String expected) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login(rawEmail, "pwd"); + + verify(loginAttemptRepository).recordAttempt(eq(expected), anyBoolean(), any(Instant.class)); + } + + @Test + @DisplayName("The email is lowercased before recording a failed attempt") + void emailIsNormalizedBeforeRecordingFailure() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.INVALID_CREDENTIALS)); + + assertThrows(BusinessException.class, () -> useCase.login("ANA@Example.COM", "pwd")); + + verify(loginAttemptRepository).recordAttempt(eq("ana@example.com"), eq(false), any(Instant.class)); + } + + @Test + @DisplayName("The normalized email, not the raw one, is sent to the identity provider") + void normalizedEmailIsSentToProvider() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login("ANA@EXAMPLE.COM", "pwd"); + + verify(identityProvider).requestPasswordToken("ana@example.com", "pwd"); + } + } + + @Nested + @DisplayName("Sliding window query") + class WindowQuery { + + @Test + @DisplayName("Recent failures are looked up from now minus the lock window") + void queriesFailuresSinceNowMinusWindow() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login("ana@example.com", "pwd"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Instant.class); + verify(loginAttemptRepository).findFailuresSince(anyString(), captor.capture()); + assertEquals(NOW.minus(LOCK_WINDOW), captor.getValue()); + } + } + + @Nested + @DisplayName("Successful login (happy path)") + class SuccessfulLogin { + + @Test + @DisplayName("A correct login returns the tokens issued by the identity provider") + void returnsProviderTokens() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + + @Test + @DisplayName("A correct login is recorded as a successful attempt at the current instant") + void recordsSuccessTrue() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login("ana@example.com", "pwd"); + + verify(loginAttemptRepository).recordAttempt("ana@example.com", true, NOW); + } + } + + // ----------------------------------------------------------------- // + // Decision table: which upstream errors count as a failed attempt // + // (also covers both operands of the compound OR in the catch block) // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Failed-attempt bookkeeping (decision table over UpstreamAuthError)") + class AttemptBookkeeping { + + @ParameterizedTest(name = "{0} counts as a failed attempt") + @EnumSource(value = UpstreamAuthError.class, + names = {"INVALID_CREDENTIALS", "EMAIL_NOT_CONFIRMED"}) + @DisplayName("Credential-related upstream errors count towards the lock") + void credentialErrorsAreRecorded(UpstreamAuthError error) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenThrow(upstream(error)); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verify(loginAttemptRepository).recordAttempt("ana@example.com", false, NOW); + } + + @ParameterizedTest(name = "{0} does not count as a failed attempt") + @EnumSource(value = UpstreamAuthError.class, + names = {"RATE_LIMITED", "UNAVAILABLE", "USER_NOT_FOUND", + "USER_ALREADY_EXISTS", "TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("Infrastructure or unrelated upstream errors never count towards the lock") + void nonCredentialErrorsAreNotRecorded(UpstreamAuthError error) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenThrow(upstream(error)); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verify(loginAttemptRepository, never()).recordAttempt(anyString(), anyBoolean(), any(Instant.class)); + } + } + + // ----------------------------------------------------------------- // + // White box: every arm of the mapUpstream switch // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream error translation (every arm of the mapping switch)") + class UpstreamMapping { + + @ParameterizedTest(name = "{0} is reported to the caller as {1}") + @CsvSource({ + "INVALID_CREDENTIALS, INVALID_CREDENTIALS", + "EMAIL_NOT_CONFIRMED, EMAIL_NOT_CONFIRMED", + "RATE_LIMITED, RATE_LIMITED", + "USER_ALREADY_EXISTS, UPSTREAM_AUTH_ERROR", + "USER_NOT_FOUND, UPSTREAM_AUTH_ERROR", + "TOKEN_INVALID, UPSTREAM_AUTH_ERROR", + "TOKEN_EXPIRED, UPSTREAM_AUTH_ERROR", + "UNAVAILABLE, UPSTREAM_AUTH_ERROR" + }) + @DisplayName("Each upstream error maps to its business error code") + void mapsEachUpstreamError(UpstreamAuthError error, ErrorCode expected) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(expected, ex.errorCode()); + } + + @Test + @DisplayName("An unmapped upstream failure keeps the provider message for diagnosis") + void unmappedErrorKeepsUpstreamMessage() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.UNAVAILABLE)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals("gotrue said UNAVAILABLE", ex.getMessage()); + } + + @Test + @DisplayName("A mapped upstream failure does not leak the provider message") + void mappedErrorUsesDefaultMessage() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.INVALID_CREDENTIALS)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.INVALID_CREDENTIALS.defaultMessage(), ex.getMessage()); + } + + @Test + @DisplayName("A rejected login carries no extra details to the client") + void failedLoginCarriesNoDetails() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.INVALID_CREDENTIALS)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertTrue(ex.details().isEmpty()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/LogoutUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/LogoutUseCaseTest.java new file mode 100644 index 0000000..52c3e9e --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/LogoutUseCaseTest.java @@ -0,0 +1,126 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the logout business rules (HU-021). + * + * Techniques applied: + * - Decision table over UpstreamAuthError: the two "session already dead" values + * are absorbed (logout is idempotent), every other value is escalated. + * - Branch and condition coverage: both operands of the compound OR in the catch + * block, the catch branch itself and the normal flow. + * - Interaction verification: the access token reaches the provider untouched. + */ +class LogoutUseCaseTest { + + private IdentityProviderPort identityProvider; + private LogoutUseCase useCase; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + useCase = new LogoutUseCase(identityProvider); + } + + @Nested + @DisplayName("Happy path") + class HappyPath { + + @Test + @DisplayName("Logout revokes the session at the identity provider with the given access token") + void revokesSessionAtProvider() { + doNothing().when(identityProvider).signOut(anyString()); + + useCase.logout("jwt-access-token"); + + verify(identityProvider).signOut("jwt-access-token"); + } + + @Test + @DisplayName("A revoked session returns normally without raising anything") + void successReturnsQuietly() { + doNothing().when(identityProvider).signOut(anyString()); + + assertDoesNotThrow(() -> useCase.logout("jwt-access-token")); + } + } + + // ----------------------------------------------------------------- // + // Decision table / state transition: session already invalid // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Idempotency (state transition: the session is already dead)") + class AlreadyInvalidSession { + + @ParameterizedTest(name = "{0} is treated as a successful logout") + @EnumSource(value = UpstreamAuthError.class, names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("Logging out an already invalid or expired session succeeds") + void alreadyInvalidSessionIsTreatedAsSuccess(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "session gone")).when(identityProvider).signOut(anyString()); + + assertDoesNotThrow(() -> useCase.logout("stale-token")); + } + } + + // ----------------------------------------------------------------- // + // Exceptional cases: everything the use case must escalate // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream failures") + class UpstreamFailures { + + @ParameterizedTest(name = "{0} is escalated as UPSTREAM_AUTH_ERROR") + @EnumSource(value = UpstreamAuthError.class, + names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any other upstream failure is escalated as an upstream auth error") + void otherUpstreamErrorsAreEscalated(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue down")).when(identityProvider).signOut(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.logout("token")); + + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("An escalated upstream failure keeps the provider message for diagnosis") + void escalatedFailureKeepsUpstreamMessage() { + doThrow(new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out")) + .when(identityProvider).signOut(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.logout("token")); + + assertEquals("gotrue timed out", ex.getMessage()); + } + + @Test + @DisplayName("A non-auth runtime failure is not swallowed by the logout use case") + void unrelatedRuntimeFailurePropagates() { + doThrow(new IllegalStateException("connection reset")).when(identityProvider).signOut(anyString()); + + assertThrows(IllegalStateException.class, () -> useCase.logout("token")); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java new file mode 100644 index 0000000..be7356d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java @@ -0,0 +1,164 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpStatus; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for the password recovery request (HU-021). + * + * Techniques applied: + * - Equivalence partitioning on the outcome of the recovery request: known email, + * unknown email (anti-enumeration partition) and provider failure. + * - Decision table over UpstreamAuthError: only USER_NOT_FOUND is absorbed. + * - Branch coverage: normal flow, the catch branch, and both outcomes of the + * USER_NOT_FOUND guard inside it. + * - Interaction verification: the recovery request always reaches the provider. + */ +class PasswordRecoveryUseCaseTest { + + private IdentityProviderPort identityProvider; + private PasswordRecoveryUseCase useCase; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + useCase = new PasswordRecoveryUseCase(identityProvider); + } + + @Nested + @DisplayName("Happy path") + class HappyPath { + + @Test + @DisplayName("A recovery request for a known email is delegated to the identity provider") + void delegatesToProvider() { + doNothing().when(identityProvider).sendPasswordRecovery(anyString()); + + useCase.requestRecovery("ana@example.com"); + + verify(identityProvider).sendPasswordRecovery("ana@example.com"); + } + + @ParameterizedTest(name = "[{0}] is forwarded unchanged") + @ValueSource(strings = {"ana@example.com", "ANA@EXAMPLE.COM", "Ana.Perez@Example.Com"}) + @DisplayName("The email is forwarded to the provider exactly as received") + void forwardsEmailAsReceived(String email) { + doNothing().when(identityProvider).sendPasswordRecovery(anyString()); + + useCase.requestRecovery(email); + + verify(identityProvider).sendPasswordRecovery(email); + } + } + + // ----------------------------------------------------------------- // + // Security rule: no user enumeration through the recovery endpoint // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Anti-enumeration") + class AntiEnumeration { + + @Test + @DisplayName("A recovery request for an unknown email succeeds so accounts cannot be enumerated") + void unknownEmailIsAnsweredAsSuccess() { + doThrow(new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "user not found")) + .when(identityProvider).sendPasswordRecovery(anyString()); + + assertDoesNotThrow(() -> useCase.requestRecovery("ghost@example.com")); + } + + @Test + @DisplayName("An unknown email is still sent to the provider before being absorbed") + void unknownEmailStillReachesProvider() { + doThrow(new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "user not found")) + .when(identityProvider).sendPasswordRecovery(anyString()); + + useCase.requestRecovery("ghost@example.com"); + + verify(identityProvider).sendPasswordRecovery("ghost@example.com"); + } + } + + // ----------------------------------------------------------------- // + // Exceptional cases // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream failures") + class UpstreamFailures { + + @ParameterizedTest(name = "{0} is translated into a business rejection") + @EnumSource(value = UpstreamAuthError.class, + names = {"USER_NOT_FOUND", "RATE_LIMITED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any upstream failure other than an unknown user is reported as an upstream auth error") + void otherUpstreamErrorsBecomeUpstreamAuthError(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).sendPasswordRecovery(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("ANTI-ENUMERATION: a provider rate limit is answered 429, never as a generic 500") + void rateLimitIsTranslatedInsteadOfEscaping() { + doThrow(new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many emails")) + .when(identityProvider).sendPasswordRecovery(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertEquals(ErrorCode.RATE_LIMITED, ex.errorCode()); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, ex.errorCode().status()); + } + + @ParameterizedTest(name = "{0} never escapes as a raw UpstreamAuthException") + @EnumSource(value = UpstreamAuthError.class, names = "USER_NOT_FOUND", mode = EnumSource.Mode.EXCLUDE) + @DisplayName("No upstream failure escapes unmapped, so none of them can surface as a 500") + void noUpstreamErrorEscapesUnmapped(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).sendPasswordRecovery(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertNotEquals(ErrorCode.INTERNAL_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("The upstream message is kept in the rejection so the trace stays useful") + void keepsTheUpstreamMessage() { + doThrow(new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out")) + .when(identityProvider).sendPasswordRecovery(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertEquals("gotrue timed out", ex.getMessage()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java new file mode 100644 index 0000000..0b44067 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java @@ -0,0 +1,251 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Unit tests for the password reset with a one-time recovery token (HU-021). + * + * Techniques applied: + * - Boundary value analysis on the password length (7/8 and 72/73 characters). + * - Equivalence partitioning on each password policy rule (uppercase, lowercase, + * digit, special character) plus the null partition. + * - Decision table over UpstreamAuthError: the two token values are translated, + * everything else is propagated. + * - Branch coverage: the weak-password guard, the normal flow, the catch branch + * and both operands of the compound OR inside it. + * - Interaction verification: a weak password must never reach the provider. + */ +class PasswordResetUseCaseTest { + + /** Shortest password that satisfies every rule: upper, lower, digit, special. */ + private static final String VALID_PASSWORD = "Abcdef1!"; + + private IdentityProviderPort identityProvider; + private PasswordResetUseCase useCase; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + useCase = new PasswordResetUseCase(identityProvider); + } + + private static String padded(int totalLength) { + return VALID_PASSWORD + "x".repeat(totalLength - VALID_PASSWORD.length()); + } + + static Stream lengthBoundaries() { + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of("Abcde1!", false), // 7: below minimum + org.junit.jupiter.params.provider.Arguments.of(VALID_PASSWORD, true), // 8: minimum + org.junit.jupiter.params.provider.Arguments.of(padded(9), true), // 9: just inside + org.junit.jupiter.params.provider.Arguments.of(padded(71), true), // 71: just inside + org.junit.jupiter.params.provider.Arguments.of(padded(72), true), // 72: maximum + org.junit.jupiter.params.provider.Arguments.of(padded(73), false)); // 73: above maximum + } + + // ----------------------------------------------------------------- // + // Black box: boundary value analysis on the password length // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Password length (boundary value analysis at 8 and 72 characters)") + class LengthBoundaries { + + @ParameterizedTest(name = "a {0}-character password is accepted = {1}") + @MethodSource("com.codefactory.bookingplatform.auth.application.PasswordResetUseCaseTest#lengthBoundaries") + @DisplayName("Only passwords between 8 and 72 characters are accepted for the reset") + void lengthBoundariesAreEnforced(String password, boolean accepted) { + doNothing().when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + if (accepted) { + assertDoesNotThrow(() -> useCase.resetPassword("token", password)); + } else { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", password)); + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + } + } + + // ----------------------------------------------------------------- // + // Black box: one equivalence partition per password policy rule // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Password strength (one equivalence partition per policy rule)") + class PasswordStrength { + + @ParameterizedTest(name = "[{0}] is rejected: {1}") + @CsvSource({ + "abcdef1!, uppercase", + "ABCDEF1!, lowercase", + "Abcdefg!, digit", + "Abcdefg1, special character" + }) + @DisplayName("A password missing any required character class is rejected as too weak") + void weakPasswordIsRejected(String password, String missingRule) { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @ParameterizedTest(name = "[{0}] never reaches the identity provider") + @ValueSource(strings = {"abcdef1!", "ABCDEF1!", "Abcdefg!", "Abcdefg1", "short1!"}) + @DisplayName("A weak password is rejected before the identity provider is contacted") + void weakPasswordDoesNotReachProvider(String password) { + assertThrows(BusinessException.class, () -> useCase.resetPassword("token", password)); + + verifyNoInteractions(identityProvider); + } + + @ParameterizedTest + @NullSource + @DisplayName("A null password is rejected as too weak instead of blowing up") + void nullPasswordIsRejected(String password) { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @Test + @DisplayName("The rejection lists the violated rules in the violations detail") + void rejectionListsViolations() { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", "abc")); + + String violations = ex.details().get("violations"); + assertTrue(violations.contains("at least 8 characters") + && violations.contains("uppercase") + && violations.contains("digit") + && violations.contains("special"), + "violations detail should list every broken rule but was: " + violations); + } + + @Test + @DisplayName("The rejection uses the PASSWORD_TOO_WEAK default message") + void rejectionUsesDefaultMessage() { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", "abc")); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK.defaultMessage(), ex.getMessage()); + } + } + + @Nested + @DisplayName("Happy path") + class HappyPath { + + @Test + @DisplayName("A strong password is sent to the provider together with the recovery token") + void strongPasswordIsDelegated() { + doNothing().when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + useCase.resetPassword("token-hash", VALID_PASSWORD); + + verify(identityProvider).resetPasswordWithToken("token-hash", VALID_PASSWORD); + } + } + + // ----------------------------------------------------------------- // + // Exceptional cases: the recovery token is no longer usable // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Recovery token failures") + class TokenFailures { + + @ParameterizedTest(name = "{0} is reported as VERIFICATION_TOKEN_INVALID") + @EnumSource(value = UpstreamAuthError.class, names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("An invalid or expired recovery token is reported as an invalid verification token") + void badTokenIsTranslated(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "token rejected")) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertEquals(ErrorCode.VERIFICATION_TOKEN_INVALID, ex.errorCode()); + } + + @ParameterizedTest(name = "{0} is translated into a business rejection") + @EnumSource(value = UpstreamAuthError.class, + names = {"TOKEN_INVALID", "TOKEN_EXPIRED", "RATE_LIMITED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any other upstream failure during the reset is reported as an upstream auth error") + void otherUpstreamErrorsBecomeUpstreamAuthError(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("A provider rate limit during the reset is answered 429, never as a generic 500") + void rateLimitIsTranslatedInsteadOfEscaping() { + doThrow(new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many resets")) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertEquals(ErrorCode.RATE_LIMITED, ex.errorCode()); + } + + @ParameterizedTest(name = "{0} never escapes as a raw UpstreamAuthException") + @EnumSource(UpstreamAuthError.class) + @DisplayName("No upstream failure escapes unmapped, so none of them can surface as a 500") + void noUpstreamErrorEscapesUnmapped(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertNotEquals(ErrorCode.INTERNAL_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("The upstream message is kept in the rejection so the trace stays useful") + void keepsTheUpstreamMessage() { + doThrow(new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out")) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertEquals("gotrue timed out", ex.getMessage()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java new file mode 100644 index 0000000..df48475 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java @@ -0,0 +1,435 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.AppRole; +import com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the auth module facade used by the other business modules. + * + * Techniques applied: + * - Boundary value analysis and equivalence partitioning on the password policy + * applied at provisioning time. + * - Decision table over UpstreamAuthError for mapUpstream: every one of the eight + * enum values is exercised through provisionClientUser, which reaches the switch + * unconditionally. + * - Branch coverage: the weak-password guard, every catch block, the two + * anti-enumeration and token guards, and the compensation catch. + * - Interaction verification: the provider must not be contacted with a weak + * password, and the role must always be CLIENT. + */ +class UserProvisioningServiceTest { + + private static final String VALID_PASSWORD = "Abcdef1!"; + + private IdentityProviderPort identityProvider; + private UserProvisioningService service; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + service = new UserProvisioningService(identityProvider); + } + + private UpstreamAuthException upstream(UpstreamAuthError error) { + return new UpstreamAuthException(error, "gotrue said " + error); + } + + // ================================================================= // + // provisionClientUser // + // ================================================================= // + + @Nested + @DisplayName("Provisioning a client user: password policy (partitions and boundaries)") + class ProvisioningPasswordPolicy { + + @ParameterizedTest(name = "[{0}] is rejected because it has no {1}") + @CsvSource({ + "abcdef1!, uppercase", + "ABCDEF1!, lowercase", + "Abcdefg!, digit", + "Abcdefg1, special character", + "Abc1!, minimum length" + }) + @DisplayName("A password that breaks the policy is rejected as too weak") + void weakPasswordIsRejected(String password, String brokenRule) { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @Test + @DisplayName("The weak password rejection carries the policy message, not an empty detail") + void weakPasswordCarriesTheDefaultMessage() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", "abcdef1!")); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK.defaultMessage(), ex.getMessage()); + } + + @ParameterizedTest(name = "[{0}] never reaches the identity provider") + @ValueSource(strings = {"abcdef1!", "ABCDEF1!", "Abcdefg!", "Abcdefg1", "Abc1!"}) + @DisplayName("A weak password is rejected before any user is created upstream") + void weakPasswordDoesNotReachProvider(String password) { + assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", password)); + + verifyNoInteractions(identityProvider); + } + + @ParameterizedTest + @NullSource + @DisplayName("A null password is rejected as too weak instead of blowing up") + void nullPasswordIsRejected(String password) { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @Test + @DisplayName("The rejection lists every broken rule in the violations detail") + void rejectionListsViolations() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", "abc")); + + String violations = ex.details().get("violations"); + assertTrue(violations.contains("at least 8 characters") + && violations.contains("uppercase") + && violations.contains("digit") + && violations.contains("special"), + "violations detail should list every broken rule but was: " + violations); + } + + @Test + @DisplayName("A password at the 8-character minimum is accepted") + void minimumLengthPasswordIsAccepted() { + UUID id = UUID.randomUUID(); + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))).thenReturn(id); + + assertEquals(id, service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + } + + @Test + @DisplayName("A password at the 72-character maximum is accepted") + void maximumLengthPasswordIsAccepted() { + UUID id = UUID.randomUUID(); + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))).thenReturn(id); + + String atMax = VALID_PASSWORD + "x".repeat(72 - VALID_PASSWORD.length()); + + assertEquals(id, service.provisionClientUser("ana@example.com", atMax)); + } + + @Test + @DisplayName("A password one character above the 72-character maximum is rejected") + void aboveMaximumLengthPasswordIsRejected() { + String aboveMax = VALID_PASSWORD + "x".repeat(73 - VALID_PASSWORD.length()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", aboveMax)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + } + + @Nested + @DisplayName("Provisioning a client user: happy path") + class ProvisioningHappyPath { + + @Test + @DisplayName("A valid signup returns the identifier issued by the identity provider") + void returnsProviderUserId() { + UUID id = UUID.randomUUID(); + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))).thenReturn(id); + + assertEquals(id, service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + } + + @Test + @DisplayName("Self-provisioned users are always created with the CLIENT role") + void alwaysCreatesClientRole() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenReturn(UUID.randomUUID()); + + service.provisionClientUser("ana@example.com", VALID_PASSWORD); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AppRole.class); + verify(identityProvider).createUser(anyString(), anyString(), captor.capture()); + assertEquals(AppRole.CLIENT, captor.getValue()); + } + + @Test + @DisplayName("The credentials are forwarded to the provider exactly as received") + void forwardsCredentials() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenReturn(UUID.randomUUID()); + + service.provisionClientUser("ana@example.com", VALID_PASSWORD); + + verify(identityProvider).createUser("ana@example.com", VALID_PASSWORD, AppRole.CLIENT); + } + } + + // ----------------------------------------------------------------- // + // Decision table: the complete mapUpstream switch (8 of 8 arms) // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream error translation (every arm of mapUpstream)") + class UpstreamMapping { + + @ParameterizedTest(name = "{0} is reported to the caller as {1}") + @CsvSource({ + "USER_ALREADY_EXISTS, DUPLICATE_EMAIL", + "RATE_LIMITED, RATE_LIMITED", + "USER_NOT_FOUND, RESOURCE_NOT_FOUND", + "TOKEN_INVALID, VERIFICATION_TOKEN_INVALID", + "TOKEN_EXPIRED, VERIFICATION_TOKEN_INVALID", + "INVALID_CREDENTIALS, INVALID_CREDENTIALS", + "EMAIL_NOT_CONFIRMED, EMAIL_NOT_CONFIRMED", + "UNAVAILABLE, UPSTREAM_AUTH_ERROR" + }) + @DisplayName("Each upstream error maps to its business error code during provisioning") + void mapsEachUpstreamError(UpstreamAuthError error, ErrorCode expected) { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + + assertEquals(expected, ex.errorCode()); + } + + @Test + @DisplayName("An unavailable provider keeps the upstream message for diagnosis") + void unavailableKeepsUpstreamMessage() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenThrow(upstream(UpstreamAuthError.UNAVAILABLE)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + + assertEquals("gotrue said UNAVAILABLE", ex.getMessage()); + } + + @Test + @DisplayName("A duplicate email is reported with the DUPLICATE_EMAIL default message") + void duplicateEmailUsesDefaultMessage() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenThrow(upstream(UpstreamAuthError.USER_ALREADY_EXISTS)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + + assertEquals(ErrorCode.DUPLICATE_EMAIL.defaultMessage(), ex.getMessage()); + } + } + + // ================================================================= // + // deprovisionUser: compensation, must never fail // + // ================================================================= // + + @Nested + @DisplayName("Deprovisioning (compensating action, never propagates)") + class Deprovisioning { + + @Test + @DisplayName("Deprovisioning deletes the auth user at the identity provider") + void deletesUserAtProvider() { + UUID id = UUID.randomUUID(); + doNothing().when(identityProvider).deleteUser(any(UUID.class)); + + service.deprovisionUser(id); + + verify(identityProvider).deleteUser(id); + } + + @ParameterizedTest(name = "{0} while compensating is swallowed") + @EnumSource(UpstreamAuthError.class) + @DisplayName("An upstream failure while compensating is swallowed instead of propagated") + void upstreamFailureIsSwallowed(UpstreamAuthError error) { + doThrow(upstream(error)).when(identityProvider).deleteUser(any(UUID.class)); + + assertDoesNotThrow(() -> service.deprovisionUser(UUID.randomUUID())); + } + + @Test + @DisplayName("Any runtime failure while compensating is swallowed instead of propagated") + void runtimeFailureIsSwallowed() { + doThrow(new IllegalStateException("connection reset")) + .when(identityProvider).deleteUser(any(UUID.class)); + + assertDoesNotThrow(() -> service.deprovisionUser(UUID.randomUUID())); + } + } + + // ================================================================= // + // resendSignupVerification: anti-enumeration // + // ================================================================= // + + @Nested + @DisplayName("Resending the signup verification") + class ResendSignupVerification { + + @Test + @DisplayName("The resend request is delegated to the identity provider") + void delegatesToProvider() { + doNothing().when(identityProvider).resendSignupVerification(anyString()); + + service.resendSignupVerification("ana@example.com"); + + verify(identityProvider).resendSignupVerification("ana@example.com"); + } + + @Test + @DisplayName("A resend for an unknown email succeeds so accounts cannot be enumerated") + void unknownEmailIsAnsweredAsSuccess() { + doThrow(upstream(UpstreamAuthError.USER_NOT_FOUND)) + .when(identityProvider).resendSignupVerification(anyString()); + + assertDoesNotThrow(() -> service.resendSignupVerification("ghost@example.com")); + } + + @ParameterizedTest(name = "{0} is escalated as a business error") + @EnumSource(value = UpstreamAuthError.class, names = "USER_NOT_FOUND", mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any upstream failure other than an unknown user is escalated") + void otherUpstreamErrorsAreEscalated(UpstreamAuthError error) { + doThrow(upstream(error)).when(identityProvider).resendSignupVerification(anyString()); + + assertThrows(BusinessException.class, () -> service.resendSignupVerification("ana@example.com")); + } + + @Test + @DisplayName("A rate-limited resend is escalated as RATE_LIMITED") + void rateLimitedResendIsMapped() { + doThrow(upstream(UpstreamAuthError.RATE_LIMITED)) + .when(identityProvider).resendSignupVerification(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.resendSignupVerification("ana@example.com")); + + assertEquals(ErrorCode.RATE_LIMITED, ex.errorCode()); + } + } + + // ================================================================= // + // confirmEmail // + // ================================================================= // + + @Nested + @DisplayName("Confirming the email with a one-time token") + class ConfirmEmail { + + @Test + @DisplayName("A valid token returns the confirmed user reported by the provider") + void validTokenReturnsConfirmedUser() { + ConfirmedUser confirmed = new ConfirmedUser(UUID.randomUUID(), "ana@example.com"); + when(identityProvider.verifyEmailToken(anyString())).thenReturn(confirmed); + + assertSame(confirmed, service.confirmEmail("token-hash")); + } + + @Test + @DisplayName("The token hash is forwarded to the provider exactly as received") + void forwardsTokenHash() { + when(identityProvider.verifyEmailToken(anyString())) + .thenReturn(new ConfirmedUser(UUID.randomUUID(), "ana@example.com")); + + service.confirmEmail("token-hash"); + + verify(identityProvider).verifyEmailToken("token-hash"); + } + + @ParameterizedTest(name = "{0} is reported as VERIFICATION_TOKEN_INVALID") + @EnumSource(value = UpstreamAuthError.class, names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("An invalid or expired confirmation token is reported as an invalid token") + void badTokenIsTranslated(UpstreamAuthError error) { + when(identityProvider.verifyEmailToken(anyString())).thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertEquals(ErrorCode.VERIFICATION_TOKEN_INVALID, ex.errorCode()); + } + + @Test + @DisplayName("An invalid confirmation token does not leak the provider message") + void badTokenUsesDefaultMessage() { + when(identityProvider.verifyEmailToken(anyString())) + .thenThrow(upstream(UpstreamAuthError.TOKEN_EXPIRED)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertEquals(ErrorCode.VERIFICATION_TOKEN_INVALID.defaultMessage(), ex.getMessage()); + } + + @ParameterizedTest(name = "{0} is escalated as a business error") + @EnumSource(value = UpstreamAuthError.class, + names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any other upstream failure during confirmation is escalated") + void otherUpstreamErrorsAreEscalated(UpstreamAuthError error) { + when(identityProvider.verifyEmailToken(anyString())).thenThrow(upstream(error)); + + assertThrows(BusinessException.class, () -> service.confirmEmail("token-hash")); + } + + @ParameterizedTest(name = "{0} during confirmation keeps its own error code") + @EnumSource(value = UpstreamAuthError.class, + names = {"RATE_LIMITED", "USER_ALREADY_EXISTS", "EMAIL_NOT_CONFIRMED"}) + @DisplayName("A confirmation failure that is not about the token keeps its own meaning") + void nonTokenFailuresKeepTheirOwnCode(UpstreamAuthError error) { + when(identityProvider.verifyEmailToken(anyString())).thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertNotEquals(ErrorCode.VERIFICATION_TOKEN_INVALID, ex.errorCode()); + } + + @Test + @DisplayName("Confirming an email for an unknown user is reported as RESOURCE_NOT_FOUND") + void unknownUserIsMapped() { + when(identityProvider.verifyEmailToken(anyString())) + .thenThrow(upstream(UpstreamAuthError.USER_NOT_FOUND)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertEquals(ErrorCode.RESOURCE_NOT_FOUND, ex.errorCode()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java new file mode 100644 index 0000000..b982a4e --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java @@ -0,0 +1,38 @@ +package com.codefactory.bookingplatform.auth.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Esta excepción cruza la frontera del adaptador llevando el error ya clasificado, que es lo + * que permite a la capa de aplicación decidir el estado HTTP sin interpretar el mensaje del + * proveedor. Si al serializarse perdiera ese dato, la clasificación se perdería con él. + */ +class UpstreamAuthExceptionTest { + + @Test + @DisplayName("A serialised upstream failure keeps its classified error and message") + void survivesJavaSerialisation() throws Exception { + UpstreamAuthException original = + new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many calls"); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(original); + } + UpstreamAuthException restored; + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + restored = (UpstreamAuthException) in.readObject(); + } + + assertEquals(UpstreamAuthError.RATE_LIMITED, restored.error()); + assertEquals("too many calls", restored.getMessage()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java index f2b7c15..abe7801 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java @@ -1,15 +1,33 @@ package com.codefactory.bookingplatform.auth.domain.service; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * HU-021 - sliding-window lock after repeated failed logins. + * + *

Black box: equivalence partitions over the number of failures inside the window (below / at / + * above the threshold) and over the position of an attempt relative to the window (before, on the + * lower edge, inside, on {@code now}, after {@code now}); boundary value analysis at + * {@code now - lockWindow}, at {@code now} and at {@code max - 1 / max / max + 1}. White box: both + * outcomes of the constructor guard and both operands of the compound filter condition + * {@code !isBefore(windowStart) && !isAfter(now)}, plus both outcomes of {@code count >= max}.

+ */ class LoginLockPolicyTest { private final LoginLockPolicy policy = new LoginLockPolicy(5, Duration.ofMinutes(15)); @@ -49,4 +67,222 @@ void oldFailuresAreIgnored() { now.minus(Duration.ofMinutes(20))); assertFalse(policy.isBlocked(failures, now)); } + + /** Builds {@code count} distinct failures all comfortably inside the window. */ + private List failuresInsideWindow(int count) { + List failures = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + failures.add(now.minus(Duration.ofMinutes(i))); + } + return failures; + } + + // ---------------------------------------------------------------------------------------- + // Constructor guard + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Constructor guard") + class ConstructorGuard { + + @ParameterizedTest(name = "maxFailedAttempts = {0} is rejected") + @ValueSource(ints = {0, -1, Integer.MIN_VALUE}) + @DisplayName("A non-positive maximum of failed attempts is rejected, because a lock that triggers at zero failures would block everyone") + void nonPositiveMaximumIsRejected(int maxFailedAttempts) { + assertThrows(IllegalArgumentException.class, + () -> new LoginLockPolicy(maxFailedAttempts, Duration.ofMinutes(15))); + } + + @Test + @DisplayName("The rejection message names the offending parameter") + void rejectionMessageNamesTheParameter() { + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> new LoginLockPolicy(0, Duration.ofMinutes(15))); + assertEquals("maxFailedAttempts must be positive", thrown.getMessage()); + } + + @ParameterizedTest(name = "maxFailedAttempts = {0} is accepted") + @ValueSource(ints = {1, 2, 5, Integer.MAX_VALUE}) + @DisplayName("Any positive maximum of failed attempts is accepted, one being the strictest allowed policy") + void positiveMaximumIsAccepted(int maxFailedAttempts) { + assertDoesNotThrow(() -> new LoginLockPolicy(maxFailedAttempts, Duration.ofMinutes(15))); + } + + @Test + @DisplayName("A policy of one attempt blocks on the very first failure") + void policyOfOneBlocksOnFirstFailure() { + LoginLockPolicy strict = new LoginLockPolicy(1, Duration.ofMinutes(15)); + assertTrue(strict.isBlocked(List.of(now.minus(Duration.ofSeconds(1))), now)); + } + + @Test + @DisplayName("A policy of one attempt does not block when there are no failures") + void policyOfOneDoesNotBlockWithoutFailures() { + LoginLockPolicy strict = new LoginLockPolicy(1, Duration.ofMinutes(15)); + assertFalse(strict.isBlocked(List.of(), now)); + } + } + + // ---------------------------------------------------------------------------------------- + // Configuration accessors + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Configuration accessors") + class ConfigurationAccessors { + + @Test + @DisplayName("The policy exposes the configured maximum of failed attempts") + void exposesMaxFailedAttempts() { + assertEquals(5, policy.maxFailedAttempts()); + } + + @Test + @DisplayName("The policy exposes the configured lock window") + void exposesLockWindow() { + assertEquals(Duration.ofMinutes(15), policy.lockWindow()); + } + } + + // ---------------------------------------------------------------------------------------- + // Threshold: boundary value analysis at max - 1, max and max + 1 + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Threshold") + class Threshold { + + @Test + @DisplayName("An empty list of failures never blocks") + void emptyListDoesNotBlock() { + assertFalse(policy.isBlocked(List.of(), now)); + } + + @ParameterizedTest(name = "{0} failures inside the window -> blocked = {1}") + @CsvSource({ + "0, false", + "1, false", + "4, false", + "5, true", + "6, true", + "20, true" + }) + @DisplayName("The account is blocked as soon as the failures inside the window reach the configured maximum") + void blocksFromTheThresholdUpwards(int failureCount, boolean expectedBlocked) { + assertEquals(expectedBlocked, policy.isBlocked(failuresInsideWindow(failureCount), now)); + } + + @Test + @DisplayName("Exactly one failure short of the maximum does not block") + void oneBelowTheThresholdDoesNotBlock() { + assertFalse(policy.isBlocked(failuresInsideWindow(4), now)); + } + + @Test + @DisplayName("Exactly the maximum blocks, so the threshold is inclusive") + void exactlyTheThresholdBlocks() { + assertTrue(policy.isBlocked(failuresInsideWindow(5), now)); + } + + @Test + @DisplayName("One failure past the maximum still blocks") + void oneAboveTheThresholdBlocks() { + assertTrue(policy.isBlocked(failuresInsideWindow(6), now)); + } + } + + // ---------------------------------------------------------------------------------------- + // Sliding window: boundary value analysis at now - lockWindow and at now + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Sliding window") + class SlidingWindow { + + private final LoginLockPolicy singleFailurePolicy = new LoginLockPolicy(1, Duration.ofMinutes(15)); + + @Test + @DisplayName("A failure exactly at now minus the lock window is counted, so the lower edge is inclusive") + void lowerEdgeIsInclusive() { + Instant onTheEdge = now.minus(Duration.ofMinutes(15)); + assertTrue(singleFailurePolicy.isBlocked(List.of(onTheEdge), now)); + } + + @Test + @DisplayName("A failure one millisecond before the lock window starts is discarded") + void justBeforeTheLowerEdgeIsExcluded() { + Instant justOutside = now.minus(Duration.ofMinutes(15)).minusMillis(1); + assertFalse(singleFailurePolicy.isBlocked(List.of(justOutside), now)); + } + + @Test + @DisplayName("A failure one millisecond after the lock window starts is counted") + void justInsideTheLowerEdgeIsCounted() { + Instant justInside = now.minus(Duration.ofMinutes(15)).plusMillis(1); + assertTrue(singleFailurePolicy.isBlocked(List.of(justInside), now)); + } + + @Test + @DisplayName("A failure stamped exactly at now is counted, so the upper edge is inclusive") + void upperEdgeIsInclusive() { + assertTrue(singleFailurePolicy.isBlocked(List.of(now), now)); + } + + @Test + @DisplayName("A failure stamped one millisecond after now is discarded as a future attempt") + void futureAttemptsAreExcluded() { + Instant future = now.plusMillis(1); + assertFalse(singleFailurePolicy.isBlocked(List.of(future), now)); + } + + @Test + @DisplayName("Future failures never contribute to the lock, no matter how many there are") + void manyFutureAttemptsStillDoNotBlock() { + List future = List.of( + now.plusSeconds(1), + now.plusSeconds(2), + now.plusSeconds(3), + now.plusSeconds(4), + now.plusSeconds(5), + now.plusSeconds(6)); + assertFalse(policy.isBlocked(future, now)); + } + + @Test + @DisplayName("Only the failures inside the window count towards the threshold when old, current and future ones are mixed") + void onlyAttemptsInsideTheWindowAreCounted() { + List mixed = List.of( + now.minus(Duration.ofHours(1)), + now.minus(Duration.ofMinutes(16)), + now.minus(Duration.ofMinutes(15)), + now.minus(Duration.ofMinutes(1)), + now, + now.plusSeconds(30)); + assertFalse(policy.isBlocked(mixed, now), + "Only three of the six attempts fall inside the window, which is below the maximum of five"); + } + + @Test + @DisplayName("Five failures inside the window still block when they are mixed with out-of-window ones") + void insideWindowFailuresBlockDespiteNoise() { + List mixed = new ArrayList<>(failuresInsideWindow(5)); + mixed.add(now.minus(Duration.ofHours(3))); + mixed.add(now.plusSeconds(90)); + assertTrue(policy.isBlocked(mixed, now)); + } + + @Test + @DisplayName("A zero-length window only counts failures stamped exactly at now") + void zeroLengthWindowCountsOnlyNow() { + LoginLockPolicy instantaneous = new LoginLockPolicy(1, Duration.ZERO); + assertTrue(instantaneous.isBlocked(List.of(now), now)); + } + + @Test + @DisplayName("A zero-length window discards a failure from one millisecond ago") + void zeroLengthWindowDiscardsThePast() { + LoginLockPolicy instantaneous = new LoginLockPolicy(1, Duration.ZERO); + assertFalse(instantaneous.isBlocked(List.of(now.minusMillis(1)), now)); + } + } } diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/service/PasswordPolicyTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/service/PasswordPolicyTest.java index 6a8bbe0..2c1a2df 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/service/PasswordPolicyTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/service/PasswordPolicyTest.java @@ -1,15 +1,53 @@ package com.codefactory.bookingplatform.auth.domain.service; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import java.util.List; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * HU-021 - password security policy. + * + *

Black box: equivalence partitions over length (too short / valid / too long) and over the four + * character classes; boundary value analysis at 7-8 and 72-73; decision table over the combinations + * of missing classes. White box: every {@code if} in {@link PasswordPolicy#violations(String)}, + * both sides of the compound conditions {@code password == null || length < MIN} and + * {@code password != null && length > MAX}, and both outcomes of each of the four regex probes.

+ */ class PasswordPolicyTest { + private static final String TOO_SHORT = "Password must be at least 8 characters long"; + private static final String TOO_LONG = "Password must be at most 72 characters long"; + private static final String NO_UPPER = "Password must contain at least one uppercase letter"; + private static final String NO_LOWER = "Password must contain at least one lowercase letter"; + private static final String NO_DIGIT = "Password must contain at least one digit"; + private static final String NO_SPECIAL = "Password must contain at least one special character"; + + /** + * Builds a password of exactly {@code length} characters that satisfies every character-class + * rule, so the only variable left under test is the length itself. + */ + private static String passwordOfLength(int length) { + String seed = "Aa1!"; + if (length <= seed.length()) { + return seed.substring(0, length); + } + return seed + "x".repeat(length - seed.length()); + } + @Test @DisplayName("Strong password passes the policy") void strongPasswordIsValid() { @@ -40,4 +78,244 @@ private void assertEqualsAtLeast(List violations, int minimum) { assertTrue(violations.size() >= minimum, "Expected at least " + minimum + " violations but got " + violations); } + + // ---------------------------------------------------------------------------------------- + // Length: boundary value analysis around MIN_LENGTH (8) and MAX_LENGTH (72) + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Length rule") + class LengthRule { + + @Test + @DisplayName("The policy declares 8 and 72 as the accepted length boundaries") + void boundariesAreEightAndSeventyTwo() { + assertEquals(8, PasswordPolicy.MIN_LENGTH); + assertEquals(72, PasswordPolicy.MAX_LENGTH); + } + + @ParameterizedTest(name = "length {0} is rejected as too short") + @ValueSource(ints = {1, 7}) + @DisplayName("A password shorter than 8 characters is rejected for length even if it has every character class") + void belowMinimumLengthIsRejected(int length) { + List violations = PasswordPolicy.violations(passwordOfLength(length)); + assertTrue(violations.contains(TOO_SHORT), () -> "Expected the too-short violation, got " + violations); + } + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {8, 9, 71, 72}) + @DisplayName("A password between 8 and 72 characters that meets every character class is accepted") + void lengthsInsideTheRangeAreAccepted(int length) { + String password = passwordOfLength(length); + assertIterableEquals(List.of(), PasswordPolicy.violations(password), + () -> "Expected no violations for a valid password of length " + length); + } + + @ParameterizedTest(name = "length {0} is rejected as too long") + @ValueSource(ints = {73, 100}) + @DisplayName("A password longer than 72 characters is rejected for length") + void aboveMaximumLengthIsRejected(int length) { + List violations = PasswordPolicy.violations(passwordOfLength(length)); + assertTrue(violations.contains(TOO_LONG), () -> "Expected the too-long violation, got " + violations); + } + + @Test + @DisplayName("Exactly 72 characters is the last accepted length and 73 is the first rejected one") + void maximumBoundaryIsInclusive() { + assertTrue(PasswordPolicy.isValid(passwordOfLength(72))); + assertFalse(PasswordPolicy.isValid(passwordOfLength(73))); + } + + @Test + @DisplayName("Exactly 8 characters is the first accepted length and 7 is the last rejected one") + void minimumBoundaryIsInclusive() { + assertTrue(PasswordPolicy.isValid(passwordOfLength(8))); + assertFalse(PasswordPolicy.isValid(passwordOfLength(7))); + } + + @Test + @DisplayName("An over-long password whose only defect is the length reports just that one violation") + void tooLongReportsOnlyTheLengthViolation() { + assertIterableEquals(List.of(TOO_LONG), PasswordPolicy.violations(passwordOfLength(73))); + } + } + + // ---------------------------------------------------------------------------------------- + // Null and empty: the degenerate partitions + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Null and empty input") + class NullAndEmptyInput { + + @Test + @DisplayName("A null password reports only the minimum-length violation, because no character rule can be evaluated") + void nullReportsOnlyTheLengthViolation() { + assertIterableEquals(List.of(TOO_SHORT), PasswordPolicy.violations(null)); + } + + @Test + @DisplayName("An empty password breaks the length rule and all four character-class rules at once") + void emptyPasswordBreaksEveryRule() { + List violations = PasswordPolicy.violations(""); + assertIterableEquals(List.of(TOO_SHORT, NO_UPPER, NO_LOWER, NO_DIGIT, NO_SPECIAL), violations); + } + + @Test + @DisplayName("An empty password reports the four character-class violations, the only input that can break all four") + void emptyPasswordReportsTheFourCharacterClassViolations() { + List violations = PasswordPolicy.violations(""); + assertTrue(violations.containsAll(List.of(NO_UPPER, NO_LOWER, NO_DIGIT, NO_SPECIAL)), + () -> "Expected the four character-class violations, got " + violations); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("Neither null nor an empty password is ever valid") + void nullAndEmptyAreNeverValid(String password) { + assertFalse(PasswordPolicy.isValid(password)); + } + + @Test + @DisplayName("A blank password made only of spaces still fails upper, lower and digit but not the special rule") + void blankPasswordFailsEveryRuleButSpecial() { + List violations = PasswordPolicy.violations(" "); + assertIterableEquals(List.of(NO_UPPER, NO_LOWER, NO_DIGIT), violations); + } + } + + // ---------------------------------------------------------------------------------------- + // Character classes: one requirement broken at a time (decision table) + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Character-class rules") + class CharacterClassRules { + + @ParameterizedTest(name = "\"{0}\" reports exactly [{1}]") + @CsvSource({ + "str0ng!pass, Password must contain at least one uppercase letter", + "STR0NG!PASS, Password must contain at least one lowercase letter", + "Strong!Pass, Password must contain at least one digit", + "Str0ngPassw, Password must contain at least one special character" + }) + @DisplayName("Breaking a single character-class requirement reports that requirement and nothing else") + void singleMissingClassReportsOnlyThatViolation(String password, String expectedViolation) { + assertIterableEquals(List.of(expectedViolation), PasswordPolicy.violations(password)); + } + + @Test + @DisplayName("A password missing three character classes reports the three of them in rule order") + void threeMissingClassesAreAllReported() { + assertIterableEquals(List.of(NO_UPPER, NO_DIGIT, NO_SPECIAL), PasswordPolicy.violations("todolowercase")); + } + + @ParameterizedTest(name = "\"{0}\" is accepted") + @ValueSource(strings = { + "Str0ng!Pass", + "Contraseña1", + "Passw0rd here", + "Aa1€€€€€", + "Aa1¡¿test", + "Aa1_underscore", + "Aa1\tTabbed" + }) + @DisplayName("Any non-alphanumeric character counts as special, including accents, symbols, spaces and tabs") + void nonAlphanumericCharactersCountAsSpecial(String password) { + assertIterableEquals(List.of(), PasswordPolicy.violations(password), + () -> "Expected \"" + password + "\" to be accepted"); + } + + @Test + @DisplayName("A space alone satisfies the special-character requirement") + void spaceCountsAsSpecialCharacter() { + assertTrue(PasswordPolicy.isValid("Passw0rd here")); + } + + @Test + @DisplayName("An accented letter satisfies the special-character requirement because the rule is non-ASCII-alphanumeric") + void accentedLetterCountsAsSpecialCharacter() { + assertTrue(PasswordPolicy.isValid("Contraseña1")); + } + + @Test + @DisplayName("Uppercase and lowercase rules only accept ASCII letters, so a non-ASCII-only password fails both") + void nonAsciiLettersDoNotSatisfyTheCaseRules() { + List violations = PasswordPolicy.violations("ÑÑññÑÑññ1"); + assertIterableEquals(List.of(NO_UPPER, NO_LOWER), violations); + } + } + + // ---------------------------------------------------------------------------------------- + // isValid must always agree with violations().isEmpty() + // ---------------------------------------------------------------------------------------- + + static Stream passwordCorpus() { + return Stream.of( + null, + "", + " ", + "Aa1!", + passwordOfLength(7), + passwordOfLength(8), + passwordOfLength(71), + passwordOfLength(72), + passwordOfLength(73), + "str0ng!pass", + "STR0NG!PASS", + "Strong!Pass", + "Str0ngPassw", + "todolowercase", + "Contraseña1", + "Passw0rd here", + "12345678", + "!!!!!!!!", + "Str0ng!Pass"); + } + + @ParameterizedTest(name = "isValid({0}) is {1}") + @CsvSource({ + // La contraseña, y si la política debe aceptarla. El veredicto se escribe aquí, + // no se calcula: comparar isValid contra violations().isEmpty() asevera X == X, + // porque isValid está implementado exactamente así, y pasaría aunque la política + // estuviese desactivada por completo. + "'Segura#2026', true", + "'Abcdefg1!', true", + "'Sin1nguno', false", // sin carácter especial + "'sinmayuscula1!', false", // sin mayúscula + "'SINMINUSCULA1!', false", // sin minúscula + "'SinDigitos!!', false", // sin dígito + "'Ab1!', false", // por debajo del mínimo + "'abcdefgh', false" // solo minúsculas + }) + @DisplayName("The policy accepts a password only when it satisfies every rule at once") + void isValidReflectsTheWholePolicy(String password, boolean esperado) { + assertEquals(esperado, PasswordPolicy.isValid(password), + () -> "the policy disagreed on [" + password + "]"); + } + + @Test + @DisplayName("The returned violation list is a fresh list on every call, so callers cannot poison the policy") + void violationsReturnsAFreshList() { + List first = PasswordPolicy.violations(""); + first.clear(); + assertFalse(PasswordPolicy.violations("").isEmpty(), + "Mutating a previously returned list must not affect later calls"); + } + + @Test + @DisplayName("PasswordPolicy is a utility class that cannot be instantiated") + void policyCannotBeInstantiated() throws Exception { + var constructor = PasswordPolicy.class.getDeclaredConstructor(); + assertTrue(java.lang.reflect.Modifier.isPrivate(constructor.getModifiers()), + "The only constructor must be private"); + constructor.setAccessible(true); + assertEquals(PasswordPolicy.class, constructor.newInstance().getClass()); + } + + @Test + @DisplayName("Evaluating a null password never throws a NullPointerException") + void nullPasswordNeverThrows() { + assertDoesNotThrow(() -> PasswordPolicy.violations(null)); + } } diff --git a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/persistence/LoginAttemptRepositoryAdapterTest.java b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/persistence/LoginAttemptRepositoryAdapterTest.java new file mode 100644 index 0000000..b509510 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/persistence/LoginAttemptRepositoryAdapterTest.java @@ -0,0 +1,126 @@ +package com.codefactory.bookingplatform.auth.infrastructure.persistence; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Translation contract of the login attempt adapter. The JPA repository is mocked on purpose: + * what is under test is the normalisation and the domain translation, not the database + * (real persistence is already covered by the Testcontainers integration tests). + */ +class LoginAttemptRepositoryAdapterTest { + + private LoginAttemptJpaRepository jpaRepository; + private LoginAttemptRepositoryAdapter adapter; + + @BeforeEach + void setUp() { + jpaRepository = mock(LoginAttemptJpaRepository.class); + adapter = new LoginAttemptRepositoryAdapter(jpaRepository); + } + + @ParameterizedTest(name = "[{index}] \"{0}\" is stored as \"{1}\"") + @CsvSource({ + "ana.perez@example.com, ana.perez@example.com", + "ANA.PEREZ@EXAMPLE.COM, ana.perez@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com" + }) + @DisplayName("The email is normalised to lower case before it is stored, so lockout counts one identity") + void recordAttemptNormalisesTheEmail(String input, String expected) { + Instant attemptedAt = Instant.parse("2026-09-22T10:15:30Z"); + + adapter.recordAttempt(input, false, attemptedAt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoginAttemptEntity.class); + verify(jpaRepository).save(captor.capture()); + assertEquals(expected, captor.getValue().getEmail()); + assertEquals(attemptedAt, captor.getValue().getAttemptedAt()); + } + + @ParameterizedTest(name = "[{index}] success={0} is persisted as is") + @CsvSource({"true", "false"}) + @DisplayName("Both successful and failed attempts are recorded, the outcome is kept verbatim") + void recordAttemptKeepsTheOutcome(boolean success) { + adapter.recordAttempt("ana.perez@example.com", success, Instant.parse("2026-09-22T10:15:30Z")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoginAttemptEntity.class); + verify(jpaRepository).save(captor.capture()); + assertEquals(success, captor.getValue().isSuccess()); + } + + @Test + @DisplayName("findFailuresSince queries with the normalised email and the caller's window start") + void findFailuresSinceNormalisesTheEmailAndForwardsTheWindow() { + Instant since = Instant.parse("2026-09-22T10:00:00Z"); + when(jpaRepository.findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter(anyString(), any())) + .thenReturn(List.of()); + + adapter.findFailuresSince("ANA.Perez@Example.COM", since); + + ArgumentCaptor email = ArgumentCaptor.forClass(String.class); + ArgumentCaptor from = ArgumentCaptor.forClass(Instant.class); + verify(jpaRepository).findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter(email.capture(), from.capture()); + assertEquals("ana.perez@example.com", email.getValue()); + assertEquals(since, from.getValue()); + verifyNoMoreInteractions(jpaRepository); + } + + @Test + @DisplayName("findFailuresSince hands the domain plain timestamps, in the order the repository returned them") + void findFailuresSinceTranslatesEntitiesToInstants() { + Instant since = Instant.parse("2026-09-22T10:00:00Z"); + Instant first = since.plus(1, ChronoUnit.MINUTES); + Instant second = since.plus(3, ChronoUnit.MINUTES); + when(jpaRepository.findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter("ana.perez@example.com", since)) + .thenReturn(List.of( + new LoginAttemptEntity("ana.perez@example.com", false, first), + new LoginAttemptEntity("ana.perez@example.com", false, second))); + + List failures = adapter.findFailuresSince("ana.perez@example.com", since); + + assertEquals(List.of(first, second), failures); + } + + @Test + @DisplayName("An account with no recent failures yields an empty list, never null") + void findFailuresSinceReturnsEmptyListWhenThereAreNoFailures() { + when(jpaRepository.findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter(anyString(), any())) + .thenReturn(List.of()); + + List failures = adapter.findFailuresSince("ana.perez@example.com", Instant.now()); + + assertTrue(failures.isEmpty()); + } + + @Test + @DisplayName("A new login attempt entity carries no identity until the database assigns one") + void newEntityHasNoIdentityYet() { + Instant attemptedAt = Instant.parse("2026-09-22T10:15:30Z"); + + LoginAttemptEntity entity = new LoginAttemptEntity("ana.perez@example.com", false, attemptedAt); + + assertNull(entity.getId()); + assertEquals("ana.perez@example.com", entity.getEmail()); + assertFalse(entity.isSuccess()); + assertEquals(attemptedAt, entity.getAttemptedAt()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java index 721fc8c..59300fa 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java @@ -1,43 +1,552 @@ package com.codefactory.bookingplatform.auth.infrastructure.supabase; +import com.codefactory.bookingplatform.auth.domain.model.AppRole; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; import com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; import com.codefactory.bookingplatform.shared.config.SupabaseProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; +import java.io.IOException; import java.util.UUID; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.headerDoesNotExist; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; /** - * GoTrue answers OTP verification with a session carrying the user nested + * Contract tests for the Supabase Auth (GoTrue) adapter. + * + *

GoTrue answers OTP verification with a session carrying the user nested * ({"access_token": ..., "user": {...}}), not with the bare user object. - * These tests lock that parsing contract. + * These tests lock that parsing contract, the header/security contract + * (the secret key must only travel to admin endpoints) and the full decision + * table that translates provider failures into {@link UpstreamAuthError}. */ class GoTrueClientTest { + private static final String BASE = "https://demo.supabase.co"; + private static final String SECRET = "secret"; + private MockRestServiceServer server; private GoTrueClient client; @BeforeEach void setUp() { - RestClient.Builder realBuilder = RestClient.builder().baseUrl("https://demo.supabase.co"); + RestClient.Builder realBuilder = RestClient.builder().baseUrl(BASE); server = MockRestServiceServer.bindTo(realBuilder).build(); RestClient.Builder builder = mock(RestClient.Builder.class); when(builder.baseUrl(anyString())).thenReturn(builder); when(builder.build()).thenReturn(realBuilder.build()); - client = new GoTrueClient(new SupabaseProperties("https://demo.supabase.co", "secret"), builder); + client = new GoTrueClient(new SupabaseProperties(BASE, SECRET), builder); + } + + // --------------------------------------------------------------------- + // Endpoint catalogue: lets the error matrices drive every operation + // through the same table without duplicating the request plumbing. + // --------------------------------------------------------------------- + + enum Endpoint { + CREATE_USER, + DELETE_USER, + TOKEN, + VERIFY, + RESET, + RESEND, + RECOVER, + LOGOUT + } + + private void invoke(Endpoint endpoint) { + switch (endpoint) { + case CREATE_USER -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + case DELETE_USER -> client.deleteUser(UUID.randomUUID()); + case TOKEN -> client.requestPasswordToken("ana.perez@example.com", "Secret123!"); + case VERIFY -> client.verifyEmailToken("token-hash"); + case RESET -> client.resetPasswordWithToken("token-hash", "NewSecret123!"); + case RESEND -> client.resendSignupVerification("ana.perez@example.com"); + case RECOVER -> client.sendPasswordRecovery("ana.perez@example.com"); + case LOGOUT -> client.signOut("user-access-token"); + } + } + + /** The path each endpoint must call. Anchoring it here makes every row of the tables below + * verify the operation as well as the classification: without it the matcher accepts any + * request and an endpoint calling the wrong path would go unnoticed. */ + private static String pathOf(Endpoint endpoint) { + return switch (endpoint) { + case CREATE_USER, DELETE_USER -> "/admin/users"; + case TOKEN -> "/token"; + case VERIFY, RESET -> "/verify"; + case RESEND -> "/resend"; + case RECOVER -> "/recover"; + case LOGOUT -> "/logout"; + }; + } + + /** Answers the request with the given status and body, then runs the endpoint. */ + private UpstreamAuthException callExpectingFailure(Endpoint endpoint, int status, String body) { + String esperado = pathOf(endpoint); + server.expect(request -> assertTrue(request.getURI().getPath().startsWith(esperado), + () -> endpoint + " called " + request.getURI().getPath() + " instead of " + esperado)) + .andRespond(withStatus(HttpStatusCode.valueOf(status)).body(body)); + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> invoke(endpoint)); + server.verify(); + return ex; + } + + static Stream endpointContexts() { + return Stream.of( + Arguments.of(Endpoint.CREATE_USER, "createUser"), + Arguments.of(Endpoint.DELETE_USER, "deleteUser"), + Arguments.of(Endpoint.TOKEN, "token"), + Arguments.of(Endpoint.VERIFY, "verify"), + // El reseteo comparte el endpoint /verify, y por tanto su contexto. + Arguments.of(Endpoint.RESET, "verify"), + Arguments.of(Endpoint.RESEND, "resend"), + Arguments.of(Endpoint.RECOVER, "recover"), + Arguments.of(Endpoint.LOGOUT, "logout")); + } + + @ParameterizedTest(name = "{0} classifies its failures under the \"{1}\" context") + @MethodSource("endpointContexts") + @DisplayName("Each endpoint hands the mapper its own context, which is what decides the classification") + void everyEndpointCarriesItsOwnErrorContext(Endpoint endpoint, String context) { + // El contexto es un literal que decide cómo se traduce el fallo: con "token" un 400 es + // INVALID_CREDENTIALS y con "verify" es TOKEN_INVALID. Las tablas de abajo aseveran el + // error resultante, pero hay combinaciones donde dos contextos coinciden, así que un + // literal intercambiado puede pasar inadvertido. Un 502 no lo mapea ninguna regla y cae + // en la rama por defecto, que es la única que nombra el contexto: eso lo ancla. + UpstreamAuthException ex = callExpectingFailure(endpoint, 502, ""); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("in " + context + ":"), + () -> "expected the " + context + " context in: " + ex.getMessage()); + } + + // --------------------------------------------------------------------- + // mapError: decision table (black box) + // --------------------------------------------------------------------- + + static Stream errorDecisionTable() { + return Stream.of( + // --- rule 1: HTTP 429 wins over every body and every context --- + Arguments.of(Endpoint.TOKEN, 429, "", UpstreamAuthError.RATE_LIMITED), + Arguments.of(Endpoint.CREATE_USER, 429, "{\"msg\":\"user not found\"}", UpstreamAuthError.RATE_LIMITED), + Arguments.of(Endpoint.VERIFY, 429, "{\"msg\":\"token has expired\"}", UpstreamAuthError.RATE_LIMITED), + Arguments.of(Endpoint.RECOVER, 429, "{\"msg\":\"email not confirmed\"}", UpstreamAuthError.RATE_LIMITED), + + // --- rule 2: body says the email is not confirmed --- + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"Email not confirmed\"}", UpstreamAuthError.EMAIL_NOT_CONFIRMED), + Arguments.of(Endpoint.TOKEN, 400, "{\"error_code\":\"email_not_confirmed\"}", UpstreamAuthError.EMAIL_NOT_CONFIRMED), + // body matching is case insensitive (toLowerCase(Locale.ROOT)) + Arguments.of(Endpoint.TOKEN, 401, "{\"msg\":\"EMAIL NOT CONFIRMED\"}", UpstreamAuthError.EMAIL_NOT_CONFIRMED), + + // --- rule 3: body says the user already exists --- + Arguments.of(Endpoint.CREATE_USER, 422, "{\"msg\":\"User already exists\"}", UpstreamAuthError.USER_ALREADY_EXISTS), + Arguments.of(Endpoint.CREATE_USER, 422, "{\"error_code\":\"user_exists\"}", UpstreamAuthError.USER_ALREADY_EXISTS), + Arguments.of(Endpoint.CREATE_USER, 400, "{\"error_code\":\"email_exists\"}", UpstreamAuthError.USER_ALREADY_EXISTS), + + // --- rule 4: body says not found, OR the status is 404 --- + Arguments.of(Endpoint.DELETE_USER, 400, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND), + Arguments.of(Endpoint.DELETE_USER, 404, "", UpstreamAuthError.USER_NOT_FOUND), + Arguments.of(Endpoint.RECOVER, 404, "", UpstreamAuthError.USER_NOT_FOUND), + + // --- rule 5: body says expired --- + Arguments.of(Endpoint.VERIFY, 400, "{\"msg\":\"Token has expired\"}", UpstreamAuthError.TOKEN_EXPIRED), + // the body beats the context: a 401 on /token that mentions an expired + // session is reported as TOKEN_EXPIRED, not INVALID_CREDENTIALS + Arguments.of(Endpoint.TOKEN, 401, "{\"msg\":\"session expired\"}", UpstreamAuthError.TOKEN_EXPIRED), + + // --- rule 6: context "token" --- + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"Invalid login credentials\"}", UpstreamAuthError.INVALID_CREDENTIALS), + Arguments.of(Endpoint.TOKEN, 401, "", UpstreamAuthError.INVALID_CREDENTIALS), + Arguments.of(Endpoint.TOKEN, 500, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.TOKEN, 403, "", UpstreamAuthError.UNAVAILABLE), + + // --- rule 7: context "verify" (shared by verifyEmailToken and resetPasswordWithToken) --- + Arguments.of(Endpoint.VERIFY, 400, "", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.VERIFY, 403, "", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.RESET, 400, "", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.RESET, 403, "{\"msg\":\"otp_disabled\"}", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.VERIFY, 500, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.VERIFY, 401, "", UpstreamAuthError.UNAVAILABLE), + + // --- rule 8: any other context falls through to UNAVAILABLE --- + Arguments.of(Endpoint.CREATE_USER, 400, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.CREATE_USER, 401, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.DELETE_USER, 500, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.RESEND, 400, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.RECOVER, 503, "", UpstreamAuthError.UNAVAILABLE) + ); + } + + @ParameterizedTest(name = "[{index}] {0} HTTP {1} body={2} -> {3}") + @MethodSource("errorDecisionTable") + @DisplayName("Provider failures are translated into the agreed UpstreamAuthError catalogue") + void mapsProviderFailuresToTheErrorCatalogue(Endpoint endpoint, int status, String body, UpstreamAuthError expected) { + UpstreamAuthException ex = callExpectingFailure(endpoint, status, body); + + assertEquals(expected, ex.error()); + } + + @ParameterizedTest(name = "[{index}] HTTP {1} + body \"{2}\" -> {3}") + @MethodSource("precedenceTable") + @DisplayName("Rule precedence in the error table is first-match-wins, not most-specific-wins") + void errorTableIsFirstMatchWins(Endpoint endpoint, int status, String body, UpstreamAuthError expected) { + UpstreamAuthException ex = callExpectingFailure(endpoint, status, body); + + assertEquals(expected, ex.error()); + } + + static Stream precedenceTable() { + return Stream.of( + // 429 beats a "not found" body: the rate limit is checked first. + Arguments.of(Endpoint.DELETE_USER, 429, "{\"msg\":\"User not found\"}", UpstreamAuthError.RATE_LIMITED), + // 429 beats an "already exists" body. + Arguments.of(Endpoint.CREATE_USER, 429, "{\"msg\":\"User already exists\"}", UpstreamAuthError.RATE_LIMITED), + // "email not confirmed" beats "user not found" when both appear. + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"email not confirmed\",\"hint\":\"user not found\"}", + UpstreamAuthError.EMAIL_NOT_CONFIRMED), + // P4: GoTrue answers 404 to an expired OTP. The "expired" rule is checked + // before the 404 one, so a stale verification link is TOKEN_EXPIRED and the + // user is told the link expired, not that the account does not exist. + Arguments.of(Endpoint.VERIFY, 404, "{\"msg\":\"Token has expired\"}", UpstreamAuthError.TOKEN_EXPIRED), + // P5: a bare 404 on /verify is about the one-time token, not about a user, + // so it reaches the "verify" branch and is reported as TOKEN_INVALID. + Arguments.of(Endpoint.VERIFY, 404, "", UpstreamAuthError.TOKEN_INVALID), + // "not found" in the body beats the token context: a 400 on /token whose body + // mentions a missing user is USER_NOT_FOUND, not INVALID_CREDENTIALS. + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND), + // The password reset shares the /verify context, so its 404 is classified the same way. + Arguments.of(Endpoint.RESET, 404, "", UpstreamAuthError.TOKEN_INVALID), + // The 404 exemption is about the status alone: an explicit "not found" body on + // /verify is still USER_NOT_FOUND. + Arguments.of(Endpoint.VERIFY, 404, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND), + // The exemption is scoped to /verify: an admin 404 keeps reporting a missing user. + Arguments.of(Endpoint.DELETE_USER, 404, "", UpstreamAuthError.USER_NOT_FOUND) + ); } + @Test + @DisplayName("An unmapped status keeps the failing operation in the message for troubleshooting") + void unmappedStatusCarriesTheContextInTheMessage() { + UpstreamAuthException ex = callExpectingFailure(Endpoint.RESEND, 502, ""); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("resend"), ex.getMessage()); + assertTrue(ex.getMessage().contains("502"), ex.getMessage()); + } + + @ParameterizedTest(name = "[{index}] {0} with the provider down -> UNAVAILABLE") + @EnumSource(Endpoint.class) + @DisplayName("Every operation degrades to UNAVAILABLE when the provider is unreachable") + void networkFailureDegradesToUnavailable(Endpoint endpoint) { + server.expect(request -> { }).andRespond(request -> { + throw new IOException("connection refused"); + }); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> invoke(endpoint)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertEquals("Identity provider is unreachable", ex.getMessage()); + assertNotNull(ex.getCause()); + } + + // --------------------------------------------------------------------- + // Header / security contract + // --------------------------------------------------------------------- + + @Nested + @DisplayName("Secret key exposure") + class HeaderContract { + + @Test + @DisplayName("Admin endpoints authenticate with apikey AND a bearer secret key") + void adminEndpointsSendApiKeyAndBearerSecret() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", SECRET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + SECRET)) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(id), MediaType.APPLICATION_JSON)); + + client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + + server.verify(); + } + + @Test + @DisplayName("deleteUser targets the user id on the admin endpoint with the admin headers") + void deleteUserUsesAdminHeaders() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/admin/users/" + id)) + .andExpect(method(HttpMethod.DELETE)) + .andExpect(header("apikey", SECRET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + SECRET)) + .andRespond(withSuccess()); + + client.deleteUser(id); + + server.verify(); + } + + @ParameterizedTest(name = "[{index}] {0} must not leak the secret key in Authorization") + @CsvSource({ + "TOKEN, /token?grant_type=password", + "VERIFY, /verify", + "RESET, /verify", + "RESEND, /resend", + "RECOVER, /recover" + }) + @DisplayName("Public endpoints send only the apikey: the secret key must never be bearer-exposed") + void publicEndpointsSendOnlyTheApiKey(Endpoint endpoint, String path) { + server.expect(requestTo(BASE + path)) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", SECRET)) + .andExpect(headerDoesNotExist(HttpHeaders.AUTHORIZATION)) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"r","user":{"id":"%s","email":"ana.perez@example.com"}} + """.formatted(UUID.randomUUID()), MediaType.APPLICATION_JSON)); + + invoke(endpoint); + + server.verify(); + } + + @Test + @DisplayName("signOut authenticates as the user: bearer is the session token, never the secret key") + void signOutSendsTheUserAccessTokenAsBearer() { + server.expect(requestTo(BASE + "/logout")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", SECRET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer user-access-token")) + .andRespond(withStatus(HttpStatusCode.valueOf(204))); + + client.signOut("user-access-token"); + + server.verify(); + } + } + + // --------------------------------------------------------------------- + // createUser + // --------------------------------------------------------------------- + + @Nested + @DisplayName("createUser") + class CreateUser { + + @Test + @DisplayName("Users are provisioned unconfirmed so the email verification flow stays mandatory") + void requestsAnUnconfirmedUserWithTheRequestedRole() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.password").value("Secret123!")) + .andExpect(jsonPath("$.email_confirm").value(false)) + .andExpect(jsonPath("$.app_metadata.role").value("PROVIDER")) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(id), MediaType.APPLICATION_JSON)); + + UUID created = client.createUser("ana.perez@example.com", "Secret123!", AppRole.PROVIDER); + + assertEquals(id, created); + server.verify(); + } + + @Test + @DisplayName("A response without an id is an unusable provider answer: UNAVAILABLE") + void missingIdFieldIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + + @Test + @DisplayName("An explicit null id is treated exactly like a missing id") + void nullIdFieldIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andRespond(withSuccess("{\"id\":null}", MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + + @Test + @DisplayName("A malformed id is reported as UNAVAILABLE, not as a raw IllegalArgumentException") + void malformedIdIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andRespond(withSuccess("{\"id\":\"not-a-uuid\"}", MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("not a valid UUID"), ex.getMessage()); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + } + + @Test + @DisplayName("An empty body (no payload at all) is reported as UNAVAILABLE, not as a crash") + void emptyBodyIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")).andRespond(withSuccess()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + } + + // --------------------------------------------------------------------- + // requestPasswordToken + // --------------------------------------------------------------------- + + @Nested + @DisplayName("requestPasswordToken") + class RequestPasswordToken { + + @Test + @DisplayName("A successful login returns the provider session verbatim") + void returnsTheProviderSession() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.password").value("Secret123!")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","token_type":"Bearer","expires_in":7200} + """, MediaType.APPLICATION_JSON)); + + AuthTokens tokens = client.requestPasswordToken("ana.perez@example.com", "Secret123!"); + + assertEquals("jwt", tokens.accessToken()); + assertEquals("refresh", tokens.refreshToken()); + assertEquals("Bearer", tokens.tokenType()); + assertEquals(7200L, tokens.expiresIn()); + server.verify(); + } + + @Test + @DisplayName("A session without token_type/expires_in falls back to bearer and one hour") + void appliesDefaultsWhenTheProviderOmitsTokenTypeAndExpiry() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh"} + """, MediaType.APPLICATION_JSON)); + + AuthTokens tokens = client.requestPasswordToken("ana.perez@example.com", "Secret123!"); + + assertEquals("bearer", tokens.tokenType()); + assertEquals(3600L, tokens.expiresIn()); + } + + @ParameterizedTest(name = "[{index}] missing {0} -> UNAVAILABLE") + @CsvSource({ + "access_token, {\"refresh_token\":\"refresh\"}", + "refresh_token, {\"access_token\":\"jwt\"}" + }) + @DisplayName("A session missing either token is an unusable provider answer: UNAVAILABLE") + void missingTokenFieldsAreReportedAsUnavailable(String missingField, String body) { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: " + missingField), ex.getMessage()); + } + + @Test + @DisplayName("A non numeric expires_in is an unusable provider answer: UNAVAILABLE") + void nonNumericExpiresInIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","expires_in":"never"} + """, MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("expires_in"), ex.getMessage()); + } + + @Test + @DisplayName("A decimal expires_in (valid JSON number) is reported as UNAVAILABLE too") + void decimalExpiresInIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","expires_in":3600.0} + """, MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("expires_in"), ex.getMessage()); + } + + @Test + @DisplayName("The failed expires_in conversion is kept as the cause for troubleshooting") + void badExpiresInKeepsTheConversionFailureAsCause() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","expires_in":"never"} + """, MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertInstanceOf(NumberFormatException.class, ex.getCause()); + } + } + + // --------------------------------------------------------------------- + // verify: verifyEmailToken / resetPasswordWithToken + // --------------------------------------------------------------------- + @Test @DisplayName("verifyEmailToken reads id/email from the nested session user") void verifyEmailTokenReadsNestedUser() { @@ -69,4 +578,295 @@ void verifyEmailTokenSupportsBareUser() { assertEquals("ana.perez@example.com", confirmed.email()); server.verify(); } + + @Nested + @DisplayName("verify endpoint") + class Verify { + + @Test + @DisplayName("Email verification posts the OTP hash with type=email and no password") + void emailVerificationSendsTypeEmailWithoutPassword() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.token_hash").value("token-hash")) + .andExpect(jsonPath("$.type").value("email")) + .andExpect(jsonPath("$.password").doesNotExist()) + .andRespond(withSuccess("{\"id\":\"%s\",\"email\":\"ana.perez@example.com\"}".formatted(id), + MediaType.APPLICATION_JSON)); + + client.verifyEmailToken("token-hash"); + + server.verify(); + } + + @Test + @DisplayName("A password reset posts the OTP hash with type=recovery and the new password") + void passwordResetSendsTypeRecoveryWithThePassword() { + server.expect(requestTo(BASE + "/verify")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.token_hash").value("token-hash")) + .andExpect(jsonPath("$.type").value("recovery")) + .andExpect(jsonPath("$.password").value("NewSecret123!")) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(UUID.randomUUID()), + MediaType.APPLICATION_JSON)); + + client.resetPasswordWithToken("token-hash", "NewSecret123!"); + + server.verify(); + } + + @Test + @DisplayName("A 200 with no payload is handled as an empty session, not as a null pointer") + void emptyVerifyResponseIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/verify")).andRespond(withSuccess()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.verifyEmailToken("token-hash")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + + @Test + @DisplayName("A session whose user is not an object falls back to the root payload") + void nonObjectUserFallsBackToTheRootPayload() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"user\":\"ana.perez@example.com\",\"id\":\"%s\",\"email\":\"root@example.com\"}" + .formatted(id), MediaType.APPLICATION_JSON)); + + ConfirmedUser confirmed = client.verifyEmailToken("token-hash"); + + assertEquals(id, confirmed.userId()); + assertEquals("root@example.com", confirmed.email()); + } + + @Test + @DisplayName("A confirmed user without email still resolves: the id is what the caller consumes") + void missingEmailStillResolvesTheUser() { + // El correo no lo lee nadie: ConfirmEmailUseCase busca al cliente por userId. + // Exigirlo convertiria una respuesta sin ese campo en un 502 por un valor que + // se descarta, asi que se tolera su ausencia y se devuelve null, nunca "null". + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(id), MediaType.APPLICATION_JSON)); + + ConfirmedUser confirmed = client.verifyEmailToken("token-hash"); + + assertEquals(id, confirmed.userId()); + assertNull(confirmed.email(), "a missing email must never become the string \"null\""); + } + + @Test + @DisplayName("An explicit null email is treated exactly like a missing one, never as the string \"null\"") + void nullEmailNeverBecomesTheStringNull() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"id\":\"%s\",\"email\":null}".formatted(id), + MediaType.APPLICATION_JSON)); + + ConfirmedUser confirmed = client.verifyEmailToken("token-hash"); + + assertEquals(id, confirmed.userId()); + assertNull(confirmed.email(), "String.valueOf(null) would have produced the 4-char string \"null\""); + } + + @Test + @DisplayName("A malformed user id is reported as UNAVAILABLE, not as a raw IllegalArgumentException") + void malformedUserIdIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"id\":\"not-a-uuid\",\"email\":\"ana.perez@example.com\"}", + MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.verifyEmailToken("token-hash")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("not a valid UUID"), ex.getMessage()); + } + } + + // --------------------------------------------------------------------- + // resend / recover + // --------------------------------------------------------------------- + + @Nested + @DisplayName("resend and recover") + class ResendAndRecover { + + @Test + @DisplayName("Resending a verification asks GoTrue for a signup type email") + void resendSendsTheSignupType() { + server.expect(requestTo(BASE + "/resend")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.type").value("signup")) + .andRespond(withSuccess()); + + client.resendSignupVerification("ana.perez@example.com"); + + server.verify(); + } + + @Test + @DisplayName("Password recovery only sends the email, never the current password") + void recoverSendsOnlyTheEmail() { + server.expect(requestTo(BASE + "/recover")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.password").doesNotExist()) + .andRespond(withSuccess()); + + client.sendPasswordRecovery("ana.perez@example.com"); + + server.verify(); + } + + @Test + @DisplayName("Resending too often surfaces the provider rate limit") + void resendTooOftenIsRateLimited() { + UpstreamAuthException ex = callExpectingFailure(Endpoint.RESEND, 429, "{\"msg\":\"over_email_send_rate_limit\"}"); + + assertEquals(UpstreamAuthError.RATE_LIMITED, ex.error()); + } + } + + // --------------------------------------------------------------------- + // signOut + // --------------------------------------------------------------------- + + @Nested + @DisplayName("signOut") + class SignOut { + + @Test + @DisplayName("A successful logout completes without a payload") + void successfulLogout() { + server.expect(requestTo(BASE + "/logout")).andRespond(withStatus(HttpStatusCode.valueOf(204))); + + client.signOut("user-access-token"); + + server.verify(); + } + + @ParameterizedTest(name = "[{index}] HTTP {0} -> TOKEN_INVALID") + @CsvSource({"401", "403", "404"}) + @DisplayName("Logging out with a dead session is reported as TOKEN_INVALID, never as USER_NOT_FOUND") + void deadSessionIsTokenInvalid(int status) { + server.expect(requestTo(BASE + "/logout")) + .andRespond(withStatus(HttpStatusCode.valueOf(status)).body("{\"msg\":\"user not found\"}")); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> client.signOut("stale-token")); + + assertEquals(UpstreamAuthError.TOKEN_INVALID, ex.error()); + assertTrue(ex.getMessage().startsWith("Session is no longer valid"), ex.getMessage()); + } + + @ParameterizedTest(name = "[{index}] HTTP {0} -> {1}") + @CsvSource({ + "429, RATE_LIMITED", + "500, UNAVAILABLE", + "400, UNAVAILABLE" + }) + @DisplayName("Any other logout failure falls back to the general error table") + void otherLogoutFailuresUseTheGeneralTable(int status, UpstreamAuthError expected) { + UpstreamAuthException ex = callExpectingFailure(Endpoint.LOGOUT, status, ""); + + assertEquals(expected, ex.error()); + } + } + + // --------------------------------------------------------------------- + // Request envelope: base url, content type and the exact shape of the + // body. These are the parts a "did it call the endpoint" assertion never + // looks at, and the parts a wrong value breaks silently in production. + // --------------------------------------------------------------------- + + @Nested + @DisplayName("Request envelope") + class RequestEnvelope { + + @Test + @DisplayName("Every call is rooted at the GoTrue path of the configured Supabase project") + void clientIsRootedAtTheProjectAuthPath() { + RestClient.Builder builder = mock(RestClient.Builder.class); + when(builder.baseUrl(anyString())).thenReturn(builder); + when(builder.build()).thenReturn(RestClient.builder().build()); + + new GoTrueClient(new SupabaseProperties("https://other.supabase.co", SECRET), builder); + + verify(builder).baseUrl("https://other.supabase.co/auth/v1"); + } + + @ParameterizedTest(name = "[{index}] {0} declares application/json") + @CsvSource({ + "TOKEN, /token?grant_type=password", + "VERIFY, /verify", + "RESEND, /resend", + "RECOVER, /recover" + }) + @DisplayName("The JSON body is announced with an explicit Content-Type, not left to the converter") + void jsonBodiesDeclareTheContentType(Endpoint endpoint, String path) { + server.expect(requestTo(BASE + path)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"r","user":{"id":"%s","email":"ana.perez@example.com"}} + """.formatted(UUID.randomUUID()), MediaType.APPLICATION_JSON)); + + invoke(endpoint); + + server.verify(); + } + + @Test + @DisplayName("Creating a user announces application/json too") + void createUserDeclaresTheContentType() { + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(UUID.randomUUID()), + MediaType.APPLICATION_JSON)); + + client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + + server.verify(); + } + + @Test + @DisplayName("A new user is created unconfirmed: the confirmation must come from the emailed link") + void createUserDoesNotAutoConfirmTheEmail() { + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(jsonPath("$.email_confirm").value(false)) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(UUID.randomUUID()), + MediaType.APPLICATION_JSON)); + + client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + + server.verify(); + } + + @Test + @DisplayName("Email verification omits the password key entirely instead of sending it as null") + void emailVerificationOmitsThePasswordKeyEntirely() { + server.expect(requestTo(BASE + "/verify")) + .andExpect(content().string(not(containsString("password")))) + .andRespond(withSuccess("{\"id\":\"%s\",\"email\":\"ana.perez@example.com\"}" + .formatted(UUID.randomUUID()), MediaType.APPLICATION_JSON)); + + client.verifyEmailToken("token-hash"); + + server.verify(); + } + + @Test + @DisplayName("A dead session reports what the provider answered, so the cause is not lost") + void deadSessionKeepsTheUpstreamDetail() { + server.expect(requestTo(BASE + "/logout")) + .andRespond(withStatus(HttpStatusCode.valueOf(401)).body("{\"msg\":\"token already revoked\"}")); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> client.signOut("stale-token")); + + assertTrue(ex.getMessage().contains("token already revoked"), ex.getMessage()); + } + } } diff --git a/src/test/java/com/codefactory/bookingplatform/identity/ClientVerificationInvariantIT.java b/src/test/java/com/codefactory/bookingplatform/identity/ClientVerificationInvariantIT.java new file mode 100644 index 0000000..30007cb --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/ClientVerificationInvariantIT.java @@ -0,0 +1,79 @@ +package com.codefactory.bookingplatform.identity; + +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.support.JwtIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * HU-001 - acceptance criterion "cliente no verificado no puede confirmar reserva". + * + *

There is no booking flow in Sprint 1, so the criterion cannot be verified through an endpoint. + * What can be verified, and is not verified anywhere today, is that the invariant is + * computable from persisted state: {@code ClientTest} exercises {@code canConfirmBooking()} on an + * object built in memory, which proves nothing about a client that went through the HTTP API, the + * mapper and PostgreSQL. This test rebuilds the aggregate from the database at both ends of the + * verification transition. + * + * @see com.codefactory.bookingplatform.architecture.BookingConfirmationInvariantTest for the guard + * that fires when a booking-confirmation path is added without consulting the invariant. + */ +class ClientVerificationInvariantIT extends JwtIntegrationTestBase { + + private static final String EMAIL = "ana.perez@example.com"; + private static final String DOCUMENT = "CC-1020304050"; + + @Autowired + private ClientRepository clientRepository; + + @Test + @DisplayName("HU-001 AC: a client persisted as PENDING_VERIFICATION cannot confirm bookings") + void pendingClientCannotConfirmBookings() throws Exception { + mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content(registrationPayload(EMAIL, DOCUMENT))) + .andExpect(status().isCreated()); + + UUID clientId = identityProvider.userIdOf(EMAIL); + Client pending = reload(clientId); + assertTrue(pending.getStatus() == ClientStatus.PENDING_VERIFICATION); + assertFalse(pending.canConfirmBooking(), + "an unverified client, read back from the database, must not be able to confirm a booking"); + } + + @Test + @DisplayName("HU-001 AC: only after confirming the email does the client become able to confirm bookings") + void confirmedClientCanConfirmBookings() throws Exception { + mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content(registrationPayload(EMAIL, DOCUMENT))) + .andExpect(status().isCreated()); + UUID clientId = identityProvider.userIdOf(EMAIL); + assertFalse(reload(clientId).canConfirmBooking()); + + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\":\"" + identityProvider.currentEmailToken(EMAIL) + "\"}")) + .andExpect(status().isOk()); + + Client active = reload(clientId); + assertTrue(active.getStatus() == ClientStatus.ACTIVE); + assertTrue(active.canConfirmBooking(), + "the verification transition must be what flips the invariant, and it must survive persistence"); + } + + private Client reload(UUID clientId) { + return clientRepository.findById(clientId).orElseThrow(); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerTest.java new file mode 100644 index 0000000..0b47695 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerTest.java @@ -0,0 +1,122 @@ +package com.codefactory.bookingplatform.identity.api; + +import com.codefactory.bookingplatform.identity.api.dto.ConfirmEmailRequest; +import com.codefactory.bookingplatform.identity.api.dto.ConfirmEmailResponse; +import com.codefactory.bookingplatform.identity.api.dto.RegisterClientRequest; +import com.codefactory.bookingplatform.identity.api.dto.RegisterClientResponse; +import com.codefactory.bookingplatform.identity.api.dto.ResendVerificationRequest; +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientCommand; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.RegistrationOutcome; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The registration controller only translates between the HTTP payload and the use cases. What has + * to hold is that every field of the request reaches the command untouched and in the right slot, + * and that each endpoint talks to its own use case. + */ +class RegistrationControllerTest { + + private static final UUID CLIENT_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + private final RegisterClientUseCase registerClientUseCase = mock(RegisterClientUseCase.class); + private final ResendVerificationUseCase resendVerificationUseCase = mock(ResendVerificationUseCase.class); + private final ConfirmEmailUseCase confirmEmailUseCase = mock(ConfirmEmailUseCase.class); + + private final RegistrationController controller = new RegistrationController( + registerClientUseCase, resendVerificationUseCase, confirmEmailUseCase); + + private RegisterClientRequest request() { + return new RegisterClientRequest( + "Ana Perez", + "1017245896", + LocalDate.of(1995, 3, 14), + "ana.perez@example.com", + "+573001112233", + "Medellin", + NotificationChannel.EMAIL, + "Str0ng!Pass"); + } + + @Test + @DisplayName("Registration hands every submitted field to the use case in its own slot") + void registrationMapsTheWholeRequest() { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.PENDING_VERIFICATION)); + + controller.register(request()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegisterClientCommand.class); + verify(registerClientUseCase).register(captor.capture()); + RegisterClientCommand command = captor.getValue(); + assertEquals("Ana Perez", command.fullName()); + assertEquals("1017245896", command.document()); + assertEquals(LocalDate.of(1995, 3, 14), command.birthDate()); + assertEquals("ana.perez@example.com", command.email()); + assertEquals("+573001112233", command.phone()); + assertEquals("Medellin", command.city()); + assertEquals(NotificationChannel.EMAIL, command.notificationChannel()); + assertEquals("Str0ng!Pass", command.password()); + } + + @Test + @DisplayName("Registration answers with the client id, email and status the use case produced") + void registrationAnswersWithTheOutcome() { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.PENDING_VERIFICATION)); + + RegisterClientResponse response = controller.register(request()); + + assertEquals(CLIENT_ID, response.clientId()); + assertEquals("ana.perez@example.com", response.email()); + assertEquals(ClientStatus.PENDING_VERIFICATION, response.status()); + } + + @Test + @DisplayName("A verification resend reaches the resend use case with the submitted email") + void resendForwardsTheEmail() { + controller.resendVerification(new ResendVerificationRequest("ana.perez@example.com")); + + verify(resendVerificationUseCase).resend("ana.perez@example.com"); + } + + @Test + @DisplayName("Email confirmation forwards the one-time token hash to the confirmation use case") + void confirmForwardsTheTokenHash() { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + controller.confirmEmail(new ConfirmEmailRequest("token-hash")); + + verify(confirmEmailUseCase).confirm("token-hash"); + } + + @Test + @DisplayName("A confirmed email is answered with the activated client") + void confirmAnswersWithTheActivatedClient() { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + ConfirmEmailResponse response = controller.confirmEmail(new ConfirmEmailRequest("token-hash")); + + assertEquals(CLIENT_ID, response.clientId()); + assertEquals("ana.perez@example.com", response.email()); + assertEquals(ClientStatus.ACTIVE, response.status()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerWebTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerWebTest.java new file mode 100644 index 0000000..c06b493 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerWebTest.java @@ -0,0 +1,483 @@ +package com.codefactory.bookingplatform.identity.api; + +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientCommand; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.RegistrationOutcome; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web slice for {@link RegistrationController}: HTTP contract (status codes, + * response body, ProblemDetail mapping) with the use cases mocked away. + * Security filters are off so that only the web adapter is under test. + */ +@WebMvcTest(RegistrationController.class) +@AutoConfigureMockMvc(addFilters = false) +class RegistrationControllerWebTest { + + private static final UUID CLIENT_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final String REGISTRATIONS = "/api/v1/registrations"; + private static final String RESENDS = "/api/v1/registrations/verification-resends"; + private static final String CONFIRMATIONS = "/api/v1/registrations/email-verifications"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private RegisterClientUseCase registerClientUseCase; + + @MockitoBean + private ResendVerificationUseCase resendVerificationUseCase; + + @MockitoBean + private ConfirmEmailUseCase confirmEmailUseCase; + + /** Field name to raw JSON value; the insertion order is the declaration order of the DTO. */ + private static Map validFields() { + Map fields = new LinkedHashMap<>(); + fields.put("fullName", "\"Ana Maria Perez\""); + fields.put("document", "\"CC-1020304050\""); + fields.put("birthDate", "\"1995-04-10\""); + fields.put("email", "\"ana.perez@example.com\""); + fields.put("phone", "\"+573001234567\""); + fields.put("city", "\"Bogota\""); + fields.put("notificationChannel", "\"EMAIL\""); + fields.put("password", "\"Str0ng!Pass\""); + return fields; + } + + private static String json(Map fields) { + return fields.entrySet().stream() + .map(e -> "\"" + e.getKey() + "\": " + e.getValue()) + .collect(Collectors.joining(",\n", "{\n", "\n}")); + } + + private static String validRegistrationPayload() { + return json(validFields()); + } + + private static String payloadWithout(String field) { + Map fields = validFields(); + fields.remove(field); + return json(fields); + } + + private static String payloadWith(String field, String rawJsonValue) { + Map fields = validFields(); + fields.put(field, rawJsonValue); + return json(fields); + } + + // ---------------------------------------------------------------- register + + @Test + @DisplayName("POST /registrations answers 201 Created") + void registerAnswers201() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("POST /registrations returns clientId, email, status and message") + void registerReturnsTheExpectedBody() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID.toString())) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.status").value("PENDING_VERIFICATION")) + .andExpect(jsonPath("$.message").isNotEmpty()); + } + + @Test + @DisplayName("SECURITY: the registration response never echoes the password back") + void registerResponseDoesNotLeakThePassword() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + String body = mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("Str0ng!Pass"), "the plain password leaked into the response body"); + assertFalse(body.toLowerCase().contains("password"), "a password field leaked into the response body"); + } + + @Test + @DisplayName("POST /registrations maps every payload field onto the command, untouched") + void registerMapsTheWholePayloadOntoTheCommand() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isCreated()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegisterClientCommand.class); + verify(registerClientUseCase).register(captor.capture()); + RegisterClientCommand command = captor.getValue(); + assertEquals(new RegisterClientCommand("Ana Maria Perez", "CC-1020304050", LocalDate.of(1995, 4, 10), + "ana.perez@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"), + command); + } + + @ParameterizedTest(name = "{0} from the use case becomes HTTP {1}") + @CsvSource({ + "MINOR_NOT_ALLOWED, 400", + "DUPLICATE_EMAIL, 409", + "DUPLICATE_DOCUMENT, 409", + "PASSWORD_TOO_WEAK, 400", + "UPSTREAM_AUTH_ERROR, 502", + "RATE_LIMITED, 429", + "INTERNAL_ERROR, 500"}) + @DisplayName("A BusinessException is mapped to its declared status") + void businessExceptionsAreMappedToTheirStatus(String errorCode, int expectedStatus) throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new BusinessException(ErrorCode.valueOf(errorCode))); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().is(expectedStatus)) + .andExpect(jsonPath("$.errorCode").value(errorCode)); + } + + @Test + @DisplayName("A BusinessException carrying details exposes them under $.details") + void businessExceptionDetailsAreExposed() throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, "weak", + Map.of("violations", "at least one digit"))); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.violations").value("at least one digit")); + } + + @Test + @DisplayName("The ProblemDetail carries type, title and instance alongside the errorCode") + void problemDetailIsFullyPopulated() throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new BusinessException(ErrorCode.MINOR_NOT_ALLOWED)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type") + .value("https://bookingplatform.codefactory.com/errors/minor_not_allowed")) + .andExpect(jsonPath("$.title").value("Bad Request")) + .andExpect(jsonPath("$.instance").value(REGISTRATIONS)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + @DisplayName("An unexpected failure is hidden behind a generic 500 INTERNAL_ERROR") + void unexpectedFailureIsMaskedAsInternalError() throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new IllegalStateException("connection pool exhausted at 10.0.0.7")); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.errorCode").value("INTERNAL_ERROR")) + .andExpect(jsonPath("$.detail").value("Unexpected internal error")); + } + + @ParameterizedTest(name = "a missing {0} is rejected with 400 and named in $.details") + @ValueSource(strings = {"fullName", "document", "birthDate", "email", "phone", "city", + "notificationChannel", "password"}) + @DisplayName("Every mandatory field missing is reported as VALIDATION_ERROR") + void missingMandatoryFieldsAreReported(String field) throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWithout(field))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.details." + field).isNotEmpty()); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("A blank fullName is rejected before the use case is reached") + void blankFullNameIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("fullName", "\" \""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.fullName").value("fullName is required")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("An out of range document is rejected with its pattern message") + void outOfRangeDocumentIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("document", "\"ABCD\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.document") + .value("document must be 5-20 alphanumeric characters or hyphens")); + } + + @Test + @DisplayName("Several invalid fields are all reported in one 400 answer") + void severalInvalidFieldsAreReportedTogether() throws Exception { + Map fields = validFields(); + fields.put("document", "\"A\""); + fields.put("phone", "\"12\""); + fields.put("email", "\"not-an-email\""); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(json(fields))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.document").isNotEmpty()) + .andExpect(jsonPath("$.details.phone").isNotEmpty()) + .andExpect(jsonPath("$.details.email").isNotEmpty()); + } + + @Test + @DisplayName("A birthDate in the future is rejected by the Past constraint, not by the use case") + void futureBirthDateIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("birthDate", "\"" + LocalDate.now().plusDays(1) + "\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.birthDate").value("birthDate must be in the past")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("A syntactically broken JSON body is answered 400 VALIDATION_ERROR") + void brokenJsonIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fullName\": \"Ana\",,,")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.detail").value("Malformed request body")); + } + + @Test + @DisplayName("An unknown notificationChannel is answered 400 VALIDATION_ERROR") + void unknownNotificationChannelIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("notificationChannel", "\"PIGEON\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("A birthDate in the wrong format is answered 400 VALIDATION_ERROR") + void malformedBirthDateIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("birthDate", "\"10/04/1995\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("An empty request body is answered 400 VALIDATION_ERROR") + void emptyBodyIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + } + + // -------------------------------------------------------------- resend + + @Test + @DisplayName("POST /verification-resends answers 202 Accepted with an empty body") + void resendAnswers202() throws Exception { + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isAccepted()) + .andExpect(content().string("")); + + verify(resendVerificationUseCase).resend("ana.perez@example.com"); + } + + @Test + @DisplayName("An unknown email still gets 202, so the endpoint does not enumerate users") + void resendDoesNotEnumerateUsers() throws Exception { + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"nobody@example.com\"}")) + .andExpect(status().isAccepted()); + } + + @ParameterizedTest(name = "resend with email [{0}] is rejected with 400") + @ValueSource(strings = {"", " ", "not-an-email", "@example.com"}) + void resendRejectsInvalidEmails(String email) throws Exception { + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"" + email + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.details.email").isNotEmpty()); + + verify(resendVerificationUseCase, never()).resend(anyString()); + } + + @Test + @DisplayName("A resend failure from the provider surfaces as 502") + void resendUpstreamFailureIsMappedTo502() throws Exception { + org.mockito.Mockito.doThrow(new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR)) + .when(resendVerificationUseCase).resend(anyString()); + + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.errorCode").value("UPSTREAM_AUTH_ERROR")); + } + + // ------------------------------------------------------------- confirm + + @Test + @DisplayName("POST /email-verifications answers 200 OK") + void confirmAnswers200() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\"}")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /email-verifications returns the activated client and never the token hash") + void confirmReturnsTheActivatedClientWithoutTheToken() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + String body = mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\"}")) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID.toString())) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.message").isNotEmpty()) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("pkce_1a2b3c"), "the one-time token hash leaked into the response body"); + } + + @Test + @DisplayName("The confirmation token reaches the use case untouched") + void confirmForwardsTheTokenHash() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\"}")) + .andExpect(status().isOk()); + + verify(confirmEmailUseCase).confirm("pkce_1a2b3c"); + } + + @ParameterizedTest(name = "confirm with tokenHash [{0}] is rejected with 400") + @ValueSource(strings = {"", " "}) + void confirmRejectsBlankTokens(String tokenHash) throws Exception { + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"" + tokenHash + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.tokenHash").value("tokenHash is required")); + + verify(confirmEmailUseCase, never()).confirm(anyString()); + } + + @Test + @DisplayName("A consumed or invalid token is answered 400 VERIFICATION_TOKEN_INVALID") + void confirmWithInvalidTokenIsRejected() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenThrow(new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID)); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"already-used\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VERIFICATION_TOKEN_INVALID")); + } + + @Test + @DisplayName("A confirmed user with no client profile is answered 404 RESOURCE_NOT_FOUND") + void confirmWithoutProfileIsAnswered404() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenThrow(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Client profile not found")); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"orphan-token\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value("RESOURCE_NOT_FOUND")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegisterClientRequestValidationTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegisterClientRequestValidationTest.java new file mode 100644 index 0000000..013843f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegisterClientRequestValidationTest.java @@ -0,0 +1,353 @@ +package com.codefactory.bookingplatform.identity.api.dto; + +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDate; +import java.util.Set; +import java.util.stream.Stream; + +import static com.codefactory.bookingplatform.support.BeanValidationSupport.digits; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.emailOfLength; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.invalidProperties; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.messagesFor; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.repeat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Black box boundary analysis of the registration payload, run straight against a + * Jakarta Validator. Every constraint declared on {@link RegisterClientRequest} is + * pinned on both sides of its limit (valid / invalid equivalence classes). + */ +class RegisterClientRequestValidationTest { + + private static final LocalDate ADULT_BIRTH_DATE = LocalDate.of(1995, 4, 10); + + private static RegisterClientRequest valid() { + return new RegisterClientRequest( + "Ana Maria Perez", + "CC-1020304050", + ADULT_BIRTH_DATE, + "ana.perez@example.com", + "+573001234567", + "Bogota", + NotificationChannel.EMAIL, + "Str0ng!Pass"); + } + + private static RegisterClientRequest withFullName(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(value, v.document(), v.birthDate(), v.email(), + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withDocument(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), value, v.birthDate(), v.email(), + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withBirthDate(LocalDate value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), value, v.email(), + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withEmail(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), value, + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withPhone(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + value, v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withCity(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + v.phone(), value, v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withChannel(NotificationChannel value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + v.phone(), v.city(), value, v.password()); + } + + private static RegisterClientRequest withPassword(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + v.phone(), v.city(), v.notificationChannel(), value); + } + + @Test + @DisplayName("The reference payload raises no violation at all") + void referencePayloadIsValid() { + assertEquals(Set.of(), invalidProperties(valid())); + } + + @Nested + @DisplayName("fullName: NotBlank + Size(max = 120)") + class FullName { + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {1, 119, 120}) + void acceptedLengths(int length) { + assertTrue(messagesFor(withFullName(repeat('a', length)), "fullName").isEmpty()); + } + + @Test + @DisplayName("121 characters is one over the limit and is rejected") + void oneOverTheLimitIsRejected() { + assertEquals(Set.of("fullName must be at most 120 characters"), + messagesFor(withFullName(repeat('a', 121)), "fullName")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withFullName(value), "fullName").contains("fullName is required")); + } + } + + @Nested + @DisplayName("document: NotBlank + Pattern [A-Za-z0-9-]{5,20}") + class Document { + + @ParameterizedTest(name = "[{0}] is accepted") + @ValueSource(strings = {"ABCDE", "12345", "CC-1020304050", "ABCDEFGHIJ1234567890", "-----"}) + void acceptedDocuments(String value) { + assertTrue(messagesFor(withDocument(value), "document").isEmpty()); + } + + @ParameterizedTest(name = "[{0}] is rejected") + @ValueSource(strings = { + "ABCD", + "ABCDEFGHIJ12345678901", + "ABC DE", + "ABC_DE", + "ABCD.E"}) + void rejectedDocuments(String value) { + assertEquals(Set.of("document must be 5-20 alphanumeric characters or hyphens"), + messagesFor(withDocument(value), "document")); + } + + @Test + @DisplayName("An accented letter is outside the allowed character set") + void accentedLetterIsRejected() { + assertEquals(Set.of("document must be 5-20 alphanumeric characters or hyphens"), + messagesFor(withDocument("ABCDÉ"), "document")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withDocument(value), "document").contains("document is required")); + } + } + + @Nested + @DisplayName("birthDate: NotNull + Past") + class BirthDate { + + @Test + @DisplayName("Yesterday is in the past and is accepted") + void yesterdayIsAccepted() { + assertTrue(messagesFor(withBirthDate(LocalDate.now().minusDays(1)), "birthDate").isEmpty()); + } + + @Test + @DisplayName("Today is NOT in the past and is rejected") + void todayIsRejected() { + assertEquals(Set.of("birthDate must be in the past"), + messagesFor(withBirthDate(LocalDate.now()), "birthDate")); + } + + @Test + @DisplayName("Tomorrow is rejected") + void futureIsRejected() { + assertEquals(Set.of("birthDate must be in the past"), + messagesFor(withBirthDate(LocalDate.now().plusDays(1)), "birthDate")); + } + + @Test + @DisplayName("A null birthDate is rejected as required") + void nullIsRejected() { + assertEquals(Set.of("birthDate is required"), messagesFor(withBirthDate(null), "birthDate")); + } + } + + @Nested + @DisplayName("email: NotBlank + Email + Size(max = 160)") + class Email { + + @Test + @DisplayName("An address of exactly 160 characters is accepted") + void maxLengthIsAccepted() { + assertTrue(messagesFor(withEmail(emailOfLength(160)), "email").isEmpty()); + } + + @Test + @DisplayName("An address of 161 characters is one over the limit and is rejected") + void oneOverTheLimitIsRejected() { + assertEquals(Set.of("email must be at most 160 characters"), + messagesFor(withEmail(emailOfLength(161)), "email")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "missing-at.example.com", "a@", "@example.com", "a b@example.com"}) + void invalidFormatsAreRejected(String value) { + assertTrue(messagesFor(withEmail(value), "email").contains("email must be a valid address")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withEmail(value), "email").contains("email is required")); + } + } + + @Nested + @DisplayName("phone: NotBlank + Pattern optional plus then 7-15 digits") + class Phone { + + static Stream accepted() { + return Stream.of( + Arguments.of(digits(7)), + Arguments.of(digits(15)), + Arguments.of("+" + digits(7)), + Arguments.of("+" + digits(15))); + } + + @ParameterizedTest(name = "[{0}] is accepted") + @MethodSource("accepted") + void acceptedPhones(String value) { + assertTrue(messagesFor(withPhone(value), "phone").isEmpty()); + } + + static Stream rejected() { + return Stream.of( + Arguments.of(digits(6)), + Arguments.of(digits(16)), + Arguments.of("+" + digits(6)), + Arguments.of("+" + digits(16)), + Arguments.of("300abc4567"), + Arguments.of("+57 300 1234567"), + Arguments.of("++5730012345"), + Arguments.of("3001234567+")); + } + + @ParameterizedTest(name = "[{0}] is rejected") + @MethodSource("rejected") + void rejectedPhones(String value) { + assertEquals(Set.of("phone must contain 7-15 digits, optionally prefixed with +"), + messagesFor(withPhone(value), "phone")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withPhone(value), "phone").contains("phone is required")); + } + } + + @Nested + @DisplayName("city: NotBlank + Size(max = 80)") + class City { + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {1, 79, 80}) + void acceptedLengths(int length) { + assertTrue(messagesFor(withCity(repeat('a', length)), "city").isEmpty()); + } + + @Test + @DisplayName("81 characters is one over the limit and is rejected") + void oneOverTheLimitIsRejected() { + assertEquals(Set.of("city must be at most 80 characters"), + messagesFor(withCity(repeat('a', 81)), "city")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withCity(value), "city").contains("city is required")); + } + } + + @Nested + @DisplayName("password: NotBlank + Size(min = 8, max = 72)") + class Password { + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {8, 9, 71, 72}) + void acceptedLengths(int length) { + assertTrue(messagesFor(withPassword(repeat('a', length)), "password").isEmpty()); + } + + @ParameterizedTest(name = "length {0} is outside [8, 72] and is rejected") + @CsvSource({"1", "7", "73", "100"}) + void rejectedLengths(int length) { + assertTrue(messagesFor(withPassword(repeat('a', length)), "password") + .contains("password must be between 8 and 72 characters")); + } + + @ParameterizedTest(name = "missing or empty value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {""}) + void mandatoryValueIsRejected(String value) { + assertFalse(messagesFor(withPassword(value), "password").isEmpty()); + } + + @Test + @DisplayName("A password of eight spaces is long enough but still blank") + void eightSpacesIsBlank() { + assertEquals(Set.of("password is required"), messagesFor(withPassword(" "), "password")); + } + } + + @Nested + @DisplayName("notificationChannel: NotNull") + class Channel { + + @ParameterizedTest(name = "{0} is accepted") + @ValueSource(strings = {"EMAIL", "SMS", "WHATSAPP"}) + void everyDeclaredChannelIsAccepted(String value) { + assertTrue(messagesFor(withChannel(NotificationChannel.valueOf(value)), "notificationChannel").isEmpty()); + } + + @Test + @DisplayName("A null channel is rejected as required") + void nullIsRejected() { + assertEquals(Set.of("notificationChannel is required"), + messagesFor(withChannel(null), "notificationChannel")); + } + } + + @Test + @DisplayName("An all-null payload reports every mandatory field at once") + void allNullPayloadReportsEveryMandatoryField() { + RegisterClientRequest empty = new RegisterClientRequest(null, null, null, null, null, null, null, null); + assertEquals( + Set.of("fullName", "document", "birthDate", "email", "phone", "city", "notificationChannel", "password"), + invalidProperties(empty)); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegistrationSupportRequestValidationTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegistrationSupportRequestValidationTest.java new file mode 100644 index 0000000..af168aa --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegistrationSupportRequestValidationTest.java @@ -0,0 +1,80 @@ +package com.codefactory.bookingplatform.identity.api.dto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static com.codefactory.bookingplatform.support.BeanValidationSupport.emailOfLength; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.invalidProperties; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.messagesFor; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validation of the two small registration DTOs: email confirmation and + * verification resend. + */ +class RegistrationSupportRequestValidationTest { + + @Nested + @DisplayName("ConfirmEmailRequest") + class ConfirmEmail { + + @Test + @DisplayName("A non blank token hash raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new ConfirmEmailRequest("pkce_1a2b3c"))); + } + + @ParameterizedTest(name = "missing, empty or blank tokenHash is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " ", "\t"}) + void tokenHashIsMandatory(String value) { + assertEquals(Set.of("tokenHash is required"), + messagesFor(new ConfirmEmailRequest(value), "tokenHash")); + } + + @Test + @DisplayName("The token hash has no length ceiling: a very long opaque token is accepted") + void longTokenIsAccepted() { + assertTrue(messagesFor(new ConfirmEmailRequest("a".repeat(512)), "tokenHash").isEmpty()); + } + } + + @Nested + @DisplayName("ResendVerificationRequest") + class ResendVerification { + + @Test + @DisplayName("A well formed email raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new ResendVerificationRequest("ana@example.com"))); + } + + @ParameterizedTest(name = "missing, empty or blank email is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void emailIsMandatory(String value) { + assertTrue(messagesFor(new ResendVerificationRequest(value), "email").contains("email is required")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "a@", "@example.com", "ana perez@example.com"}) + void emailFormatIsChecked(String value) { + assertTrue(messagesFor(new ResendVerificationRequest(value), "email") + .contains("email must be a valid address")); + } + + @Test + @DisplayName("The resend DTO has no length ceiling, unlike the registration DTO") + void noMaxLengthConstraint() { + String longButValid = emailOfLength(190); + assertTrue(messagesFor(new ResendVerificationRequest(longButValid), "email").isEmpty()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/ConfirmEmailUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/ConfirmEmailUseCaseTest.java new file mode 100644 index 0000000..651a2e5 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/ConfirmEmailUseCaseTest.java @@ -0,0 +1,202 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser; +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * HU-001 email confirmation use case. + * + * Techniques applied: + * - Decision table over the two inputs that decide the outcome: token accepted + * by the provider (yes/no) and client profile present in our database + * (yes/no), crossed with the status the profile is in. + * - White-box branch coverage: the orElseThrow branch, the idempotent branch of + * Client.verifyEmail and the branch where the aggregate rejects the change. + * - Interaction testing: the profile must be persisted after the status change, + * and nothing must be persisted when a rule fails. + */ +class ConfirmEmailUseCaseTest { + + private static final UUID CLIENT_ID = UUID.fromString("44444444-4444-4444-4444-444444444444"); + private static final String TOKEN = "token-hash"; + + private ClientRepository clientRepository; + private UserProvisioning userProvisioning; + private ConfirmEmailUseCase useCase; + + @BeforeEach + void setUp() { + clientRepository = mock(ClientRepository.class); + userProvisioning = mock(UserProvisioning.class); + useCase = new ConfirmEmailUseCase(clientRepository, userProvisioning); + } + + private Client clientInStatus(ClientStatus status) { + return new Client(CLIENT_ID, "Ana Perez", "CC12345678", LocalDate.of(1995, 4, 10), + "ana@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, status); + } + + private void tokenResolvesTo(Client client) { + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)).thenReturn(Optional.of(client)); + when(clientRepository.save(any(Client.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + // --- Happy path ---------------------------------------------------------- + + @Test + @DisplayName("A valid token activates the pending client and reports the new status") + void validTokenActivatesPendingClient() { + Client pending = clientInStatus(ClientStatus.PENDING_VERIFICATION); + tokenResolvesTo(pending); + + RegistrationOutcome outcome = useCase.confirm(TOKEN); + + assertEquals(CLIENT_ID, outcome.clientId()); + assertEquals("ana@example.com", outcome.email()); + assertEquals(ClientStatus.ACTIVE, outcome.status()); + assertTrue(pending.canConfirmBooking()); + } + + @Test + @DisplayName("The activated client is persisted after the status change, not before") + void activatedClientIsPersisted() { + Client pending = clientInStatus(ClientStatus.PENDING_VERIFICATION); + tokenResolvesTo(pending); + + useCase.confirm(TOKEN); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Client.class); + verify(clientRepository).save(captor.capture()); + assertSame(pending, captor.getValue()); + assertEquals(ClientStatus.ACTIVE, captor.getValue().getStatus()); + + InOrder order = inOrder(userProvisioning, clientRepository); + order.verify(userProvisioning).confirmEmail(TOKEN); + order.verify(clientRepository).findById(CLIENT_ID); + order.verify(clientRepository).save(pending); + } + + @Test + @DisplayName("The client is looked up by the provider user id, which is also the client id") + void clientIsLookedUpByProviderUserId() { + tokenResolvesTo(clientInStatus(ClientStatus.PENDING_VERIFICATION)); + + useCase.confirm(TOKEN); + + verify(clientRepository).findById(CLIENT_ID); + } + + // --- Idempotency --------------------------------------------------------- + + @Test + @DisplayName("Confirming twice is idempotent: the second call succeeds and keeps the client ACTIVE") + void confirmingTwiceIsIdempotent() { + Client pending = clientInStatus(ClientStatus.PENDING_VERIFICATION); + tokenResolvesTo(pending); + + RegistrationOutcome first = useCase.confirm(TOKEN); + RegistrationOutcome second = useCase.confirm(TOKEN); + + assertEquals(ClientStatus.ACTIVE, first.status()); + assertEquals(ClientStatus.ACTIVE, second.status()); + assertEquals(first, second); + verify(clientRepository, times(2)).save(pending); + } + + @Test + @DisplayName("Confirming an already ACTIVE client neither fails nor changes the status") + void confirmingAnAlreadyActiveClientDoesNotFail() { + Client active = clientInStatus(ClientStatus.ACTIVE); + tokenResolvesTo(active); + + RegistrationOutcome outcome = useCase.confirm(TOKEN); + + assertEquals(ClientStatus.ACTIVE, outcome.status()); + verify(clientRepository).save(active); + } + + // --- Exceptional paths --------------------------------------------------- + + @Test + @DisplayName("A token accepted by the provider without a client profile raises RESOURCE_NOT_FOUND") + void missingClientProfileRaisesResourceNotFound() { + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.confirm(TOKEN)); + + assertEquals(ErrorCode.RESOURCE_NOT_FOUND, ex.errorCode()); + assertEquals("Client profile not found for the confirmed user", ex.getMessage()); + verify(clientRepository, never()).save(any(Client.class)); + } + + @Test + @DisplayName("An invalid or already used token fails at the provider and never touches the client profile") + void invalidTokenIsPropagatedWithoutTouchingTheRepository() { + BusinessException invalid = new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID); + when(userProvisioning.confirmEmail(anyString())).thenThrow(invalid); + + BusinessException thrown = assertThrows(BusinessException.class, () -> useCase.confirm("used-token")); + + assertSame(invalid, thrown); + verifyNoInteractions(clientRepository); + } + + @Test + @DisplayName("A suspended client cannot be reactivated through email confirmation and is not persisted") + void suspendedClientCannotBeConfirmed() { + Client suspended = clientInStatus(ClientStatus.SUSPENDED); + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)).thenReturn(Optional.of(suspended)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.confirm(TOKEN)); + + assertEquals(ErrorCode.VALIDATION_ERROR, ex.errorCode()); + assertEquals(ClientStatus.SUSPENDED, suspended.getStatus()); + assertFalse(suspended.canConfirmBooking()); + verify(clientRepository, never()).save(any(Client.class)); + } + + @Test + @DisplayName("A failure while persisting the activation is propagated to the caller") + void persistenceFailureIsPropagated() { + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)) + .thenReturn(Optional.of(clientInStatus(ClientStatus.PENDING_VERIFICATION))); + RuntimeException boom = new IllegalStateException("database down"); + when(clientRepository.save(any(Client.class))).thenThrow(boom); + + assertSame(boom, assertThrows(RuntimeException.class, () -> useCase.confirm(TOKEN))); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientUseCaseTest.java new file mode 100644 index 0000000..252238d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientUseCaseTest.java @@ -0,0 +1,388 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * HU-001 registration use case. + * + * Techniques applied: + * - Decision table over the validation rules (age, duplicate email, duplicate + * document) including the precedence between them. + * - Boundary value analysis on the age rule, driven by a fixed Clock. + * - Black-box partitioning of the normalisation rules (case and surrounding + * blanks) verified with ArgumentCaptor on the persisted aggregate. + * - White-box branch coverage: the happy path, the three guard clauses and + * both entries into the catch block that compensates the provisioning. + * - Interaction testing: what must be called, in which order, and what must + * never be called when a rule fails. + */ +class RegisterClientUseCaseTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 9, 18); + private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC); + private static final UUID PROVISIONED_ID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + private ClientRepository clientRepository; + private UserProvisioning userProvisioning; + private RegisterClientUseCase useCase; + + @BeforeEach + void setUp() { + clientRepository = mock(ClientRepository.class); + userProvisioning = mock(UserProvisioning.class); + useCase = new RegisterClientUseCase(clientRepository, userProvisioning, CLOCK); + } + + private RegisterClientCommand command() { + return command("ana@example.com", "cc12345678", LocalDate.of(1995, 4, 10)); + } + + private RegisterClientCommand command(String email, String document, LocalDate birthDate) { + return new RegisterClientCommand("Ana Perez", document, birthDate, email, + "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"); + } + + private void provisioningSucceeds() { + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(PROVISIONED_ID); + when(clientRepository.save(any(Client.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + private Client capturedSavedClient() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Client.class); + verify(clientRepository).save(captor.capture()); + return captor.getValue(); + } + + // --- Happy path ---------------------------------------------------------- + + @Test + @DisplayName("A valid adult registration provisions the credential, stores the profile and triggers the verification email") + void happyPathRegistersTheClient() { + provisioningSucceeds(); + + RegistrationOutcome outcome = useCase.register(command()); + + assertEquals(PROVISIONED_ID, outcome.clientId()); + assertEquals("ana@example.com", outcome.email()); + assertEquals(ClientStatus.PENDING_VERIFICATION, outcome.status()); + + InOrder order = inOrder(clientRepository, userProvisioning); + order.verify(clientRepository).existsByEmail("ana@example.com"); + order.verify(clientRepository).existsByDocument("CC12345678"); + order.verify(userProvisioning).provisionClientUser("ana@example.com", "Str0ng!Pass"); + order.verify(clientRepository).save(any(Client.class)); + order.verify(userProvisioning).resendSignupVerification("ana@example.com"); + order.verifyNoMoreInteractions(); + } + + @Test + @DisplayName("The stored client starts in PENDING_VERIFICATION and never in ACTIVE") + void storedClientStartsPendingVerification() { + provisioningSucceeds(); + + useCase.register(command()); + + Client saved = capturedSavedClient(); + assertEquals(ClientStatus.PENDING_VERIFICATION, saved.getStatus()); + assertEquals(PROVISIONED_ID, saved.getId()); + } + + @Test + @DisplayName("The client is never deprovisioned when the registration completes") + void noCompensationOnSuccess() { + provisioningSucceeds(); + + useCase.register(command()); + + verify(userProvisioning, never()).deprovisionUser(any(UUID.class)); + } + + // --- Normalisation ------------------------------------------------------- + + @Test + @DisplayName("Email is lowercased, document uppercased and name, phone and city are trimmed before persisting") + void normalisesEveryFieldBeforePersisting() { + provisioningSucceeds(); + RegisterClientCommand raw = new RegisterClientCommand(" Ana Perez ", " cc12345678 ", + LocalDate.of(1995, 4, 10), "Ana.Perez@EXAMPLE.COM", " +573001234567 ", " Bogota ", + NotificationChannel.SMS, "Str0ng!Pass"); + + useCase.register(raw); + + Client saved = capturedSavedClient(); + assertEquals("Ana Perez", saved.getFullName()); + assertEquals("CC12345678", saved.getDocument()); + assertEquals("ana.perez@example.com", saved.getEmail()); + assertEquals("+573001234567", saved.getPhone()); + assertEquals("Bogota", saved.getCity()); + assertEquals(NotificationChannel.SMS, saved.getNotificationChannel()); + assertEquals(LocalDate.of(1995, 4, 10), saved.getBirthDate()); + } + + @Test + @DisplayName("The identity provider receives the normalised email, not the one typed by the user") + void providerReceivesNormalisedEmail() { + provisioningSucceeds(); + + useCase.register(command("Ana.Perez@EXAMPLE.COM", "cc12345678", LocalDate.of(1995, 4, 10))); + + verify(userProvisioning).provisionClientUser("ana.perez@example.com", "Str0ng!Pass"); + verify(userProvisioning).resendSignupVerification("ana.perez@example.com"); + } + + @Test + @DisplayName("The duplicate document check runs against the normalised document, not the typed one") + void documentUniquenessIsCheckedNormalised() { + provisioningSucceeds(); + + useCase.register(command("ana@example.com", " cc-123 ab ", LocalDate.of(1995, 4, 10))); + + verify(clientRepository).existsByDocument("CC-123 AB"); + } + + @Test + @DisplayName("The duplicate email check runs against the lowercased email") + void emailUniquenessIsCheckedNormalised() { + provisioningSucceeds(); + + useCase.register(command("ANA@EXAMPLE.COM", "cc12345678", LocalDate.of(1995, 4, 10))); + + verify(clientRepository).existsByEmail("ana@example.com"); + } + + @Test + @DisplayName("DEFECT PIN: surrounding blanks in the email are not trimmed, unlike every other field") + void emailIsLowercasedButNotTrimmed() { + // Documented gap, see the QA report: RegisterClientUseCase line 45 applies + // toLowerCase without trim(), so " Ana@Example.com " reaches the provider + // and the uniqueness check with its blanks. Pinned here so the fix is visible. + provisioningSucceeds(); + + useCase.register(command(" Ana@Example.com ", "cc12345678", LocalDate.of(1995, 4, 10))); + + verify(clientRepository).existsByEmail(" ana@example.com "); + assertEquals(" ana@example.com ", capturedSavedClient().getEmail()); + } + + // --- Age rule ------------------------------------------------------------ + + @Test + @DisplayName("A minor is rejected with MINOR_NOT_ALLOWED before any repository or provider call") + void minorIsRejected() { + RegisterClientCommand minor = command("ana@example.com", "cc12345678", TODAY.minusYears(18).plusDays(1)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(minor)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, ex.errorCode()); + verifyNoInteractions(clientRepository); + verifyNoInteractions(userProvisioning); + } + + @ParameterizedTest(name = "born {0} -> accepted? {1}") + @DisplayName("The age boundary is evaluated against the fixed clock: the 18th birthday is already valid") + @CsvSource({ + "2008-09-19, false", + "2008-09-18, true", + "2008-09-17, true" + }) + void ageBoundaryAgainstFixedClock(LocalDate birthDate, boolean accepted) { + provisioningSucceeds(); + RegisterClientCommand cmd = command("ana@example.com", "cc12345678", birthDate); + + if (accepted) { + assertEquals(ClientStatus.PENDING_VERIFICATION, useCase.register(cmd).status()); + } else { + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, + assertThrows(BusinessException.class, () -> useCase.register(cmd)).errorCode()); + } + } + + @Test + @DisplayName("Age is measured with the injected clock, so a client who is a minor today is not registered") + void ageUsesTheInjectedClock() { + Clock fiveYearsEarlier = Clock.fixed( + LocalDate.of(2021, 9, 18).atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC); + RegisterClientUseCase pastUseCase = + new RegisterClientUseCase(clientRepository, userProvisioning, fiveYearsEarlier); + RegisterClientCommand justEighteenToday = command("ana@example.com", "cc12345678", TODAY.minusYears(18)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, + assertThrows(BusinessException.class, () -> pastUseCase.register(justEighteenToday)).errorCode()); + } + + // --- Uniqueness rules and their precedence ------------------------------- + + @Test + @DisplayName("A duplicate email is rejected with DUPLICATE_EMAIL before the document is even checked") + void duplicateEmailIsRejected() { + when(clientRepository.existsByEmail("ana@example.com")).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertEquals(ErrorCode.DUPLICATE_EMAIL, ex.errorCode()); + verify(clientRepository).existsByEmail("ana@example.com"); + verifyNoMoreInteractions(clientRepository); + verifyNoInteractions(userProvisioning); + } + + @Test + @DisplayName("A duplicate document is rejected with DUPLICATE_DOCUMENT and no credential is provisioned") + void duplicateDocumentIsRejected() { + when(clientRepository.existsByEmail("ana@example.com")).thenReturn(false); + when(clientRepository.existsByDocument("CC12345678")).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertEquals(ErrorCode.DUPLICATE_DOCUMENT, ex.errorCode()); + verify(clientRepository, never()).save(any(Client.class)); + verifyNoInteractions(userProvisioning); + } + + @Test + @DisplayName("A minor with an email already registered fails for being a minor, not for the duplicate") + void ageRuleWinsOverDuplicateEmail() { + when(clientRepository.existsByEmail(anyString())).thenReturn(true); + RegisterClientCommand minorWithTakenEmail = + command("ana@example.com", "cc12345678", TODAY.minusYears(10)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(minorWithTakenEmail)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, ex.errorCode()); + verifyNoInteractions(clientRepository); + } + + @Test + @DisplayName("A minor with a document already registered fails for being a minor, not for the duplicate") + void ageRuleWinsOverDuplicateDocument() { + when(clientRepository.existsByDocument(anyString())).thenReturn(true); + RegisterClientCommand minorWithTakenDocument = + command("ana@example.com", "cc12345678", TODAY.minusYears(3)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(minorWithTakenDocument)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, ex.errorCode()); + verifyNoInteractions(clientRepository); + } + + @Test + @DisplayName("A duplicate email wins over a duplicate document when both collide") + void duplicateEmailWinsOverDuplicateDocument() { + when(clientRepository.existsByEmail(anyString())).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertEquals(ErrorCode.DUPLICATE_EMAIL, ex.errorCode()); + verify(clientRepository, never()).existsByDocument(anyString()); + } + + // --- Compensation of the provisioned credential -------------------------- + + @Nested + @DisplayName("Compensation when the registration fails after provisioning") + class Compensation { + + @Test + @DisplayName("If storing the profile fails the provisioned credential is deleted and the original failure is rethrown") + void saveFailureCompensatesProvisioning() { + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(PROVISIONED_ID); + RuntimeException boom = new IllegalStateException("database down"); + when(clientRepository.save(any(Client.class))).thenThrow(boom); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> useCase.register(command())); + + assertSame(boom, thrown); + verify(userProvisioning).deprovisionUser(PROVISIONED_ID); + verify(userProvisioning, never()).resendSignupVerification(anyString()); + } + + @Test + @DisplayName("If the verification email cannot be sent the provisioned credential is deleted and the original failure is rethrown") + void resendFailureCompensatesProvisioning() { + provisioningSucceeds(); + BusinessException upstream = new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR); + doThrow(upstream).when(userProvisioning).resendSignupVerification(anyString()); + + BusinessException thrown = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertSame(upstream, thrown); + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, thrown.errorCode()); + verify(userProvisioning).deprovisionUser(PROVISIONED_ID); + } + + @Test + @DisplayName("The compensation targets exactly the id that was provisioned") + void compensationUsesTheProvisionedId() { + UUID otherId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(otherId); + when(clientRepository.save(any(Client.class))).thenThrow(new IllegalStateException("boom")); + + assertThrows(RuntimeException.class, () -> useCase.register(command())); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UUID.class); + verify(userProvisioning).deprovisionUser(captor.capture()); + assertEquals(otherId, captor.getValue()); + } + + @Test + @DisplayName("If provisioning itself fails nothing is stored and there is nothing to compensate") + void provisioningFailureNeedsNoCompensation() { + BusinessException upstream = new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR); + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenThrow(upstream); + + BusinessException thrown = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertSame(upstream, thrown); + verify(userProvisioning, never()).deprovisionUser(any(UUID.class)); + verify(clientRepository, never()).save(any(Client.class)); + } + + @Test + @DisplayName("DEFECT PIN: if the compensation also fails, its error replaces the original cause") + void compensationFailureMasksTheOriginalCause() { + // Documented gap, see the QA report: RegisterClientUseCase line 73 calls + // deprovisionUser outside any guard, so a failing compensation hides the + // real reason the registration failed. + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(PROVISIONED_ID); + when(clientRepository.save(any(Client.class))).thenThrow(new IllegalStateException("database down")); + doThrow(new IllegalStateException("provider down")).when(userProvisioning).deprovisionUser(PROVISIONED_ID); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> useCase.register(command())); + + assertEquals("provider down", thrown.getMessage()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/ResendVerificationUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/ResendVerificationUseCaseTest.java new file mode 100644 index 0000000..f864537 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/ResendVerificationUseCaseTest.java @@ -0,0 +1,146 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * HU-001 verification email resend. + * + * Techniques applied: + * - Decision table with a single condition: does the normalised email belong to + * a known client? Both outcomes are covered, including the anti-enumeration + * rule that an unknown email must look exactly like a known one to the caller. + * - Black-box partitioning of the normalisation rule (upper, mixed and lower + * case addresses). + * - White-box branch coverage: both arms of ifPresentOrElse. + * - Interaction testing: the provider must not be called for unknown emails. + */ +class ResendVerificationUseCaseTest { + + private ClientRepository clientRepository; + private UserProvisioning userProvisioning; + private ResendVerificationUseCase useCase; + + @BeforeEach + void setUp() { + clientRepository = mock(ClientRepository.class); + userProvisioning = mock(UserProvisioning.class); + useCase = new ResendVerificationUseCase(clientRepository, userProvisioning); + } + + private Client clientWithEmail(String email, ClientStatus status) { + return new Client(UUID.randomUUID(), "Ana Perez", "CC12345678", LocalDate.of(1995, 4, 10), + email, "+573001234567", "Bogota", NotificationChannel.EMAIL, status); + } + + // --- Known email --------------------------------------------------------- + + @Test + @DisplayName("A known email gets a new verification message from the identity provider") + void knownEmailTriggersTheProvider() { + when(clientRepository.findByEmail("ana@example.com")) + .thenReturn(Optional.of(clientWithEmail("ana@example.com", ClientStatus.PENDING_VERIFICATION))); + + useCase.resend("ana@example.com"); + + verify(userProvisioning).resendSignupVerification("ana@example.com"); + } + + @ParameterizedTest(name = "\"{0}\" is looked up and resent as \"{1}\"") + @DisplayName("The email is lowercased before the lookup and before the provider call") + @CsvSource({ + "ANA@EXAMPLE.COM, ana@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com", + "ana@example.com, ana@example.com" + }) + void emailIsNormalisedBeforeLookupAndResend(String typedEmail, String normalisedEmail) { + when(clientRepository.findByEmail(normalisedEmail)) + .thenReturn(Optional.of(clientWithEmail(normalisedEmail, ClientStatus.PENDING_VERIFICATION))); + + useCase.resend(typedEmail); + + verify(clientRepository).findByEmail(normalisedEmail); + verify(userProvisioning).resendSignupVerification(normalisedEmail); + } + + @ParameterizedTest + @DisplayName("The resend is offered whatever the client status is, because the provider owns that decision") + @EnumSource(ClientStatus.class) + void resendIsOfferedForEveryStatus(ClientStatus status) { + when(clientRepository.findByEmail("ana@example.com")) + .thenReturn(Optional.of(clientWithEmail("ana@example.com", status))); + + useCase.resend("ana@example.com"); + + verify(userProvisioning).resendSignupVerification("ana@example.com"); + } + + // --- Unknown email: anti-enumeration ------------------------------------- + + @Test + @DisplayName("An unknown email is silently accepted so the endpoint cannot be used to enumerate users") + void unknownEmailIsSilentlyAccepted() { + when(clientRepository.findByEmail("desconocido@example.com")).thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> useCase.resend("desconocido@example.com")); + } + + @Test + @DisplayName("An unknown email never reaches the identity provider") + void unknownEmailDoesNotReachTheProvider() { + when(clientRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + + useCase.resend("DESCONOCIDO@example.com"); + + verify(clientRepository).findByEmail("desconocido@example.com"); + verifyNoInteractions(userProvisioning); + } + + // --- Exceptional paths --------------------------------------------------- + + @Test + @DisplayName("A provider failure while resending is propagated instead of being swallowed") + void providerFailureIsPropagated() { + when(clientRepository.findByEmail("ana@example.com")) + .thenReturn(Optional.of(clientWithEmail("ana@example.com", ClientStatus.PENDING_VERIFICATION))); + BusinessException upstream = new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR); + doThrow(upstream).when(userProvisioning).resendSignupVerification("ana@example.com"); + + assertSame(upstream, assertThrows(BusinessException.class, () -> useCase.resend("ana@example.com"))); + } + + @Test + @DisplayName("DEFECT PIN: a null email breaks the resend with NullPointerException instead of a validation error") + void nullEmailFailsWithNullPointerException() { + // Documented gap, see the QA report: ResendVerificationUseCase line 29 calls + // toLowerCase on the raw argument with no null guard of its own. + assertThrows(NullPointerException.class, () -> useCase.resend(null)); + verifyNoInteractions(clientRepository); + verifyNoInteractions(userProvisioning); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java index 75374db..90568db 100644 --- a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java +++ b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java @@ -3,16 +3,31 @@ import com.codefactory.bookingplatform.shared.error.BusinessException; import com.codefactory.bookingplatform.shared.error.ErrorCode; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import java.time.LocalDate; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * Client aggregate: state machine {PENDING_VERIFICATION, ACTIVE, SUSPENDED} + * over the transitions {verifyEmail, suspend, reactivate}. + * + * Techniques applied: + * - State transition testing (the full 3x3 table is exercised cell by cell). + * - Decision table for canConfirmBooking (one row per status). + * - Branch coverage: every guard inside verifyEmail/suspend/reactivate, both + * for the path that mutates the state and for the path that throws. + */ class ClientTest { private Client newPendingClient() { @@ -21,6 +36,12 @@ private Client newPendingClient() { "Bogota", NotificationChannel.EMAIL); } + private Client clientInStatus(ClientStatus status) { + return new Client(UUID.randomUUID(), "Ana Perez", "12345678", + LocalDate.of(1995, 4, 10), "ana@example.com", "+573001234567", + "Bogota", NotificationChannel.EMAIL, status); + } + @Test @DisplayName("New client starts as PENDING_VERIFICATION and cannot confirm bookings") void newClientIsPendingVerification() { @@ -71,4 +92,172 @@ void suspensionLifecycle() { assertEquals(ClientStatus.ACTIVE, pending.getStatus()); assertTrue(pending.canConfirmBooking()); } + + // --- Factory and projection --------------------------------------------- + + @Test + @DisplayName("The pendingVerification factory keeps every attribute it receives") + void factoryKeepsEveryAttribute() { + UUID id = UUID.randomUUID(); + LocalDate birthDate = LocalDate.of(1990, 1, 31); + Client client = Client.pendingVerification(id, "Ana Perez", "CC-98765", birthDate, + "ana@example.com", "+573001234567", "Medellin", NotificationChannel.WHATSAPP); + + assertEquals(id, client.getId()); + assertEquals("Ana Perez", client.getFullName()); + assertEquals("CC-98765", client.getDocument()); + assertEquals(birthDate, client.getBirthDate()); + assertEquals("ana@example.com", client.getEmail()); + assertEquals("+573001234567", client.getPhone()); + assertEquals("Medellin", client.getCity()); + assertEquals(NotificationChannel.WHATSAPP, client.getNotificationChannel()); + assertEquals(ClientStatus.PENDING_VERIFICATION, client.getStatus()); + } + + @Test + @DisplayName("The rehydration constructor accepts any persisted status without re-running the state machine") + void rehydrationConstructorAcceptsAnyStatus() { + Client suspended = clientInStatus(ClientStatus.SUSPENDED); + assertEquals(ClientStatus.SUSPENDED, suspended.getStatus()); + assertNotNull(suspended.getId()); + } + + // --- State transition table: 3 states x 3 transitions = 9 cells ---------- + + /** + * from | verifyEmail | suspend | reactivate + * ---------------------+----------------------+----------------------+------------------- + * PENDING_VERIFICATION | to ACTIVE | BusinessException | BusinessException + * ACTIVE | stays ACTIVE (no-op) | to SUSPENDED | BusinessException + * SUSPENDED | BusinessException | stays SUSPENDED | to ACTIVE + */ + @ParameterizedTest(name = "{0} + {1} -> {2}") + @DisplayName("State transition table: every allowed transition lands on its target status") + @CsvSource({ + "PENDING_VERIFICATION, verifyEmail, ACTIVE", + "ACTIVE, verifyEmail, ACTIVE", + "ACTIVE, suspend, SUSPENDED", + "SUSPENDED, suspend, SUSPENDED", + "SUSPENDED, reactivate, ACTIVE" + }) + void allowedTransitions(ClientStatus from, String transition, ClientStatus expected) { + Client client = clientInStatus(from); + + applyTransition(client, transition); + + assertEquals(expected, client.getStatus()); + } + + @ParameterizedTest(name = "{0} + {1} is rejected") + @DisplayName("State transition table: every forbidden transition raises VALIDATION_ERROR and leaves the status untouched") + @CsvSource({ + "PENDING_VERIFICATION, suspend", + "PENDING_VERIFICATION, reactivate", + "ACTIVE, reactivate", + "SUSPENDED, verifyEmail" + }) + void forbiddenTransitions(ClientStatus from, String transition) { + Client client = clientInStatus(from); + + BusinessException ex = assertThrows(BusinessException.class, () -> applyTransition(client, transition)); + + assertEquals(ErrorCode.VALIDATION_ERROR, ex.errorCode()); + assertNotNull(ex.getMessage()); + assertEquals(from, client.getStatus(), "a rejected transition must not mutate the aggregate"); + } + + private void applyTransition(Client client, String transition) { + switch (transition) { + case "verifyEmail" -> client.verifyEmail(); + case "suspend" -> client.suspend(); + case "reactivate" -> client.reactivate(); + default -> throw new IllegalArgumentException("Unknown transition " + transition); + } + } + + @Nested + @DisplayName("Messages of the rejected transitions") + class RejectionMessages { + + @Test + @DisplayName("Verifying a suspended client explains that the status blocks the activation") + void verifySuspendedMessage() { + Client client = clientInStatus(ClientStatus.SUSPENDED); + BusinessException ex = assertThrows(BusinessException.class, client::verifyEmail); + assertEquals("Client SUSPENDED cannot be moved to ACTIVE by email verification", ex.getMessage()); + } + + @Test + @DisplayName("Suspending a pending client explains that verification comes first") + void suspendPendingMessage() { + Client client = clientInStatus(ClientStatus.PENDING_VERIFICATION); + BusinessException ex = assertThrows(BusinessException.class, client::suspend); + assertEquals("A pending verification client cannot be suspended", ex.getMessage()); + } + + @ParameterizedTest(name = "reactivate on {0}") + @DisplayName("Reactivating a client that is not suspended explains that only suspended clients qualify") + @CsvSource({"PENDING_VERIFICATION", "ACTIVE"}) + void reactivateNonSuspendedMessage(ClientStatus from) { + Client client = clientInStatus(from); + BusinessException ex = assertThrows(BusinessException.class, client::reactivate); + assertEquals("Only suspended clients can be reactivated", ex.getMessage()); + } + } + + // --- Decision table for the booking invariant ---------------------------- + + @ParameterizedTest(name = "{0} can confirm bookings? {1}") + @DisplayName("Only an ACTIVE client may confirm bookings") + @CsvSource({ + "PENDING_VERIFICATION, false", + "ACTIVE, true", + "SUSPENDED, false" + }) + void canConfirmBookingDecisionTable(ClientStatus status, boolean expected) { + assertEquals(expected, clientInStatus(status).canConfirmBooking()); + } + + @ParameterizedTest + @DisplayName("Every declared status is covered by the booking decision table") + @EnumSource(ClientStatus.class) + void everyStatusAnswersTheBookingInvariant(ClientStatus status) { + Client client = clientInStatus(status); + assertEquals(status == ClientStatus.ACTIVE, client.canConfirmBooking()); + } + + // --- Longer paths through the machine ------------------------------------ + + @Test + @DisplayName("A client can be suspended and reactivated repeatedly without losing the booking invariant") + void suspendReactivateCycleIsRepeatable() { + Client client = newPendingClient(); + client.verifyEmail(); + + for (int cycle = 0; cycle < 3; cycle++) { + client.suspend(); + assertFalse(client.canConfirmBooking()); + client.reactivate(); + assertTrue(client.canConfirmBooking()); + } + assertEquals(ClientStatus.ACTIVE, client.getStatus()); + } + + @Test + @DisplayName("Suspending twice keeps the client suspended instead of failing") + void suspendTwiceIsIdempotent() { + Client client = clientInStatus(ClientStatus.ACTIVE); + client.suspend(); + client.suspend(); + assertEquals(ClientStatus.SUSPENDED, client.getStatus()); + } + + @Test + @DisplayName("A reactivated client can be verified again without error because verification is idempotent on ACTIVE") + void verifyAfterReactivationIsIdempotent() { + Client client = clientInStatus(ClientStatus.SUSPENDED); + client.reactivate(); + client.verifyEmail(); + assertEquals(ClientStatus.ACTIVE, client.getStatus()); + } } diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java index a07a797..59c4b21 100644 --- a/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java +++ b/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java @@ -2,12 +2,25 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.time.LocalDate; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * HU-001: self-registration is forbidden for minors. + * + * Techniques applied: + * - Equivalence partitioning: {minor} vs {adult} vs {birth date in the future}. + * - Boundary value analysis around the 18th birthday: the day before, the very + * day, and the day after. + * - Special-date analysis: 29 February of a leap year, where the anniversary + * does not exist in common years. + */ class AgePolicyTest { private static final LocalDate TODAY = LocalDate.of(2026, 9, 18); @@ -29,4 +42,92 @@ void oneDayShortOfEighteenIsMinor() { void olderAdultIsAdult() { assertTrue(AgePolicy.isAdult(TODAY.minusYears(35), TODAY)); } + + // --- Boundary value analysis: the exact 18th birthday -------------------- + + @Test + @DisplayName("The day after turning 18 is accepted") + void oneDayAfterEighteenthBirthdayIsAdult() { + assertTrue(AgePolicy.isAdult(TODAY.minusYears(18).minusDays(1), TODAY)); + } + + @ParameterizedTest(name = "born {0} -> adult on 2026-09-18? {1}") + @DisplayName("The 18th birthday is the exact boundary: before it the client is a minor, from it on an adult") + @CsvSource({ + // one year before the boundary: clearly a minor + "2009-09-18, false", + // one day before the 18th birthday: still a minor + "2008-09-19, false", + // the 18th birthday itself: adult + "2008-09-18, true", + // the day after the 18th birthday: adult + "2008-09-17, true", + // one year past the boundary: clearly an adult + "2007-09-18, true" + }) + void eighteenthBirthdayIsTheBoundary(LocalDate birthDate, boolean expectedAdult) { + assertEquals(expectedAdult, AgePolicy.isAdult(birthDate, TODAY)); + } + + @ParameterizedTest(name = "age {0} -> adult? {1}") + @DisplayName("Equivalence partitions: ages below 18 are rejected and ages from 18 up are accepted") + @CsvSource({ + "0, false", + "1, false", + "17, false", + "18, true", + "19, true", + "80, true" + }) + void equivalencePartitionsByWholeYears(int age, boolean expectedAdult) { + assertEquals(expectedAdult, AgePolicy.isAdult(TODAY.minusYears(age), TODAY)); + } + + @Test + @DisplayName("A newborn registering the same day is a minor") + void bornTodayIsMinor() { + assertFalse(AgePolicy.isAdult(TODAY, TODAY)); + } + + @Test + @DisplayName("A birth date in the future is rejected instead of wrapping into a positive age") + void futureBirthDateIsNotAdult() { + assertFalse(AgePolicy.isAdult(TODAY.plusYears(1), TODAY)); + } + + // --- Leap year: 29 February --------------------------------------------- + + @Test + @DisplayName("Someone born on 29 February is still a minor on 28 February of the common year they turn 18") + void leapDayBornIsMinorOnFebruaryTwentyEighth() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertFalse(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2026, 2, 28))); + } + + @Test + @DisplayName("Someone born on 29 February becomes an adult on 1 March of the common year they turn 18") + void leapDayBornIsAdultOnMarchFirst() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertTrue(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2026, 3, 1))); + } + + @Test + @DisplayName("Someone born on 29 February is an adult on 29 February of a later leap year") + void leapDayBornIsAdultOnTheNextLeapAnniversary() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertTrue(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2028, 2, 29))); + } + + @Test + @DisplayName("Someone born on 29 February is a minor on 28 February of the leap year before turning 18") + void leapDayBornIsMinorTheDayBeforeTheLeapAnniversary() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertFalse(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2026, 2, 27))); + } + + @Test + @DisplayName("The minimum legal age published by the policy is 18") + void minimumAgeIsEighteen() { + assertEquals(18, AgePolicy.MINIMUM_AGE); + } } diff --git a/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/mapper/ClientMapperTest.java b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/mapper/ClientMapperTest.java new file mode 100644 index 0000000..d2d87b9 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/mapper/ClientMapperTest.java @@ -0,0 +1,146 @@ +package com.codefactory.bookingplatform.identity.infrastructure.mapper; + +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientEntity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * The MapStruct mapper is the only bridge between the pure domain aggregate and the JPA entity. + * These tests lock that no field is dropped in either direction: a silent loss of status or + * notification channel would break the HU-001 rules that depend on them. + */ +class ClientMapperTest { + + private final ClientMapper mapper = new ClientMapperImpl(); + + private static Client domainClient(UUID id, NotificationChannel channel, ClientStatus status) { + return new Client(id, "Ana Perez", "1017", LocalDate.of(1998, 4, 12), "ana.perez@example.com", + "3001112233", "Medellin", channel, status); + } + + private static ClientEntity entity(UUID id, NotificationChannel channel, ClientStatus status) { + ClientEntity entity = new ClientEntity(); + entity.setId(id); + entity.setFullName("Ana Perez"); + entity.setDocument("1017"); + entity.setBirthDate(LocalDate.of(1998, 4, 12)); + entity.setEmail("ana.perez@example.com"); + entity.setPhone("3001112233"); + entity.setCity("Medellin"); + entity.setNotificationChannel(channel); + entity.setStatus(status); + return entity; + } + + @ParameterizedTest(name = "[{index}] {0} / {1} survive the trip to the entity") + @CsvSource({ + "EMAIL, PENDING_VERIFICATION", + "SMS, ACTIVE", + "WHATSAPP, SUSPENDED" + }) + @DisplayName("toEntity copies every attribute, including the status and the notification channel") + void toEntityCopiesEveryAttribute(NotificationChannel channel, ClientStatus status) { + UUID id = UUID.randomUUID(); + + ClientEntity result = mapper.toEntity(domainClient(id, channel, status)); + + assertEquals(id, result.getId()); + assertEquals("Ana Perez", result.getFullName()); + assertEquals("1017", result.getDocument()); + assertEquals(LocalDate.of(1998, 4, 12), result.getBirthDate()); + assertEquals("ana.perez@example.com", result.getEmail()); + assertEquals("3001112233", result.getPhone()); + assertEquals("Medellin", result.getCity()); + assertEquals(channel, result.getNotificationChannel()); + assertEquals(status, result.getStatus()); + } + + @ParameterizedTest(name = "[{index}] {0} / {1} survive the trip to the domain") + @CsvSource({ + "EMAIL, PENDING_VERIFICATION", + "SMS, ACTIVE", + "WHATSAPP, SUSPENDED" + }) + @DisplayName("toDomain rebuilds the aggregate with every attribute, including the status") + void toDomainCopiesEveryAttribute(NotificationChannel channel, ClientStatus status) { + UUID id = UUID.randomUUID(); + + Client result = mapper.toDomain(entity(id, channel, status)); + + assertEquals(id, result.getId()); + assertEquals("Ana Perez", result.getFullName()); + assertEquals("1017", result.getDocument()); + assertEquals(LocalDate.of(1998, 4, 12), result.getBirthDate()); + assertEquals("ana.perez@example.com", result.getEmail()); + assertEquals("3001112233", result.getPhone()); + assertEquals("Medellin", result.getCity()); + assertEquals(channel, result.getNotificationChannel()); + assertEquals(status, result.getStatus()); + } + + @Test + @DisplayName("A domain to entity to domain round trip loses nothing") + void roundTripLosesNothing() { + UUID id = UUID.randomUUID(); + Client original = domainClient(id, NotificationChannel.WHATSAPP, ClientStatus.SUSPENDED); + + Client back = mapper.toDomain(mapper.toEntity(original)); + + assertEquals(original.getId(), back.getId()); + assertEquals(original.getFullName(), back.getFullName()); + assertEquals(original.getDocument(), back.getDocument()); + assertEquals(original.getBirthDate(), back.getBirthDate()); + assertEquals(original.getEmail(), back.getEmail()); + assertEquals(original.getPhone(), back.getPhone()); + assertEquals(original.getCity(), back.getCity()); + assertEquals(original.getNotificationChannel(), back.getNotificationChannel()); + assertEquals(original.getStatus(), back.getStatus()); + } + + @Test + @DisplayName("A brand new client keeps its behaviour after the round trip: only ACTIVE may confirm bookings") + void roundTripPreservesTheBusinessState() { + Client pending = Client.pendingVerification(UUID.randomUUID(), "Ana Perez", "1017", + LocalDate.of(1998, 4, 12), "ana.perez@example.com", "3001112233", "Medellin", + NotificationChannel.EMAIL); + + Client back = mapper.toDomain(mapper.toEntity(pending)); + + assertEquals(ClientStatus.PENDING_VERIFICATION, back.getStatus()); + back.verifyEmail(); + assertEquals(ClientStatus.ACTIVE, back.getStatus()); + } + + @Test + @DisplayName("Mapping a null client yields null instead of an empty entity") + void toEntityOfNullIsNull() { + assertNull(mapper.toEntity(null)); + } + + @Test + @DisplayName("Mapping a null entity yields null instead of an empty aggregate") + void toDomainOfNullIsNull() { + assertNull(mapper.toDomain(null)); + } + + @Test + @DisplayName("The mapper does not stamp the audit columns: they belong to JPA auditing") + void mappedEntityCarriesNoAuditStamps() { + ClientEntity result = mapper.toEntity(domainClient(UUID.randomUUID(), + NotificationChannel.EMAIL, ClientStatus.ACTIVE)); + + assertNull(result.getCreatedAt()); + assertNull(result.getUpdatedAt()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/persistence/ClientRepositoryAdapterTest.java b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/persistence/ClientRepositoryAdapterTest.java new file mode 100644 index 0000000..59bf892 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/persistence/ClientRepositoryAdapterTest.java @@ -0,0 +1,175 @@ +package com.codefactory.bookingplatform.identity.infrastructure.persistence; + +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.infrastructure.mapper.ClientMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Translation contract of the client adapter. The JPA repository and the MapStruct mapper are + * mocked: what is asserted is the email normalisation and that every crossing of the boundary + * goes through the mapper. Real persistence lives in the Testcontainers integration tests. + */ +class ClientRepositoryAdapterTest { + + private ClientJpaRepository jpaRepository; + private ClientMapper mapper; + private ClientRepositoryAdapter adapter; + + @BeforeEach + void setUp() { + jpaRepository = mock(ClientJpaRepository.class); + mapper = mock(ClientMapper.class); + adapter = new ClientRepositoryAdapter(jpaRepository, mapper); + } + + private static Client sampleClient(UUID id) { + return new Client(id, "Ana Perez", "1017", LocalDate.of(1998, 4, 12), "ana.perez@example.com", + "3001112233", "Medellin", NotificationChannel.EMAIL, ClientStatus.ACTIVE); + } + + @Test + @DisplayName("findById returns the domain client mapped from the stored entity") + void findByIdMapsTheStoredEntity() { + UUID id = UUID.randomUUID(); + ClientEntity entity = new ClientEntity(); + Client domain = sampleClient(id); + when(jpaRepository.findById(id)).thenReturn(Optional.of(entity)); + when(mapper.toDomain(entity)).thenReturn(domain); + + Optional found = adapter.findById(id); + + assertTrue(found.isPresent()); + assertSame(domain, found.get()); + verify(mapper).toDomain(entity); + } + + @Test + @DisplayName("An unknown id yields an empty optional and the mapper is never invoked") + void findByIdOnUnknownIdReturnsEmpty() { + UUID id = UUID.randomUUID(); + when(jpaRepository.findById(id)).thenReturn(Optional.empty()); + + assertTrue(adapter.findById(id).isEmpty()); + verify(mapper, never()).toDomain(any()); + } + + @ParameterizedTest(name = "[{index}] lookup by \"{0}\" queries \"{1}\"") + @CsvSource({ + "ana.perez@example.com, ana.perez@example.com", + "ANA.PEREZ@EXAMPLE.COM, ana.perez@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com" + }) + @DisplayName("Email lookups are case insensitive: the address is normalised before the query") + void findByEmailNormalisesTheEmail(String input, String expected) { + UUID id = UUID.randomUUID(); + ClientEntity entity = new ClientEntity(); + when(jpaRepository.findByEmailIgnoreCase(anyString())).thenReturn(Optional.of(entity)); + when(mapper.toDomain(entity)).thenReturn(sampleClient(id)); + + Optional found = adapter.findByEmail(input); + + ArgumentCaptor email = ArgumentCaptor.forClass(String.class); + verify(jpaRepository).findByEmailIgnoreCase(email.capture()); + assertEquals(expected, email.getValue()); + assertEquals(id, found.orElseThrow().getId()); + } + + @Test + @DisplayName("An email with no client yields an empty optional") + void findByEmailOnUnknownEmailReturnsEmpty() { + when(jpaRepository.findByEmailIgnoreCase(anyString())).thenReturn(Optional.empty()); + + assertTrue(adapter.findByEmail("ana.perez@example.com").isEmpty()); + verify(mapper, never()).toDomain(any()); + } + + @ParameterizedTest(name = "[{index}] existsByEmail(\"{0}\") queries \"{1}\"") + @CsvSource({ + "ana.perez@example.com, ana.perez@example.com", + "ANA.PEREZ@EXAMPLE.COM, ana.perez@example.com" + }) + @DisplayName("Duplicate email detection normalises the address, so casing cannot bypass uniqueness") + void existsByEmailNormalisesTheEmail(String input, String expected) { + when(jpaRepository.existsByEmailIgnoreCase(expected)).thenReturn(true); + + assertTrue(adapter.existsByEmail(input)); + + ArgumentCaptor email = ArgumentCaptor.forClass(String.class); + verify(jpaRepository).existsByEmailIgnoreCase(email.capture()); + assertEquals(expected, email.getValue()); + } + + @Test + @DisplayName("A free email address reports no duplicate") + void existsByEmailIsFalseWhenTheEmailIsFree() { + when(jpaRepository.existsByEmailIgnoreCase(anyString())).thenReturn(false); + + assertFalse(adapter.existsByEmail("ana.perez@example.com")); + } + + @ParameterizedTest(name = "[{index}] document \"{0}\" is forwarded verbatim") + @ValueSource(strings = {"1017", "cc-1017", "CC-1017"}) + @DisplayName("The document is forwarded verbatim: uniqueness is enforced by the case insensitive query") + void existsByDocumentForwardsTheDocumentVerbatim(String document) { + when(jpaRepository.existsByDocumentIgnoreCase(document)).thenReturn(true); + + assertTrue(adapter.existsByDocument(document)); + + ArgumentCaptor captured = ArgumentCaptor.forClass(String.class); + verify(jpaRepository).existsByDocumentIgnoreCase(captured.capture()); + assertEquals(document, captured.getValue()); + } + + @Test + @DisplayName("An unknown document reports no duplicate") + void existsByDocumentIsFalseWhenTheDocumentIsFree() { + when(jpaRepository.existsByDocumentIgnoreCase(anyString())).thenReturn(false); + + assertFalse(adapter.existsByDocument("1017")); + } + + @Test + @DisplayName("save crosses the boundary twice: domain to entity on the way in, entity to domain on the way out") + void saveGoesThroughTheMapperInBothDirections() { + UUID id = UUID.randomUUID(); + Client incoming = sampleClient(id); + ClientEntity toPersist = new ClientEntity(); + ClientEntity persisted = new ClientEntity(); + Client outgoing = sampleClient(id); + when(mapper.toEntity(incoming)).thenReturn(toPersist); + when(jpaRepository.save(toPersist)).thenReturn(persisted); + when(mapper.toDomain(persisted)).thenReturn(outgoing); + + Client saved = adapter.save(incoming); + + assertSame(outgoing, saved); + ArgumentCaptor captor = ArgumentCaptor.forClass(ClientEntity.class); + verify(mapper).toEntity(incoming); + verify(jpaRepository).save(captor.capture()); + assertSame(toPersist, captor.getValue()); + verify(mapper).toDomain(persisted); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java new file mode 100644 index 0000000..b1ecb3b --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java @@ -0,0 +1,108 @@ +package com.codefactory.bookingplatform.shared.config; + +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.auth.infrastructure.config.AuthConfig; +import com.codefactory.bookingplatform.shared.persistence.JpaAuditingConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.time.Clock; +import java.time.Duration; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The declarative configuration classes are exercised by calling their factory + * methods directly: no application context is started, which keeps the check on + * the values produced rather than on Spring itself. + */ +class ConfigurationBeansTest { + + @Nested + @DisplayName("ClockConfig") + class ClockConfigTest { + + private final ClockConfig config = new ClockConfig(); + + @Test + @DisplayName("The clock is the system UTC clock: neither frozen, nor offset, nor on the host zone") + void clockIsSystemUtc() { + // Una sola aserción cubre las tres cosas, y de forma determinista: Clock.fixed, + // Clock.offset y systemDefaultZone son todos distintos de systemUTC. Comparar el + // instante contra "hace un minuto" no sirve, porque un reloj congelado en el + // instante actual también lo cumple; y leerlo dos veces esperando que avance + // depende de la resolución del reloj del sistema y sale intermitente en Windows. + assertEquals(Clock.systemUTC(), config.clock()); + assertEquals(ZoneOffset.UTC, config.clock().getZone()); + } + } + + @Nested + @DisplayName("AuthConfig") + class AuthConfigTest { + + private final AuthConfig config = new AuthConfig(); + + @ParameterizedTest(name = "maxFailedAttempts={0} and lockWindowMinutes={1} reach the policy untouched") + @CsvSource({"5, 15", "1, 1", "10, 60", "3, 120"}) + @DisplayName("The lock policy bean is built from the configured properties") + void policyIsBuiltFromProperties(int attempts, int minutes) { + LoginLockPolicy policy = config.loginLockPolicy(new AuthPolicyProperties(attempts, minutes)); + + assertEquals(attempts, policy.maxFailedAttempts()); + assertEquals(Duration.ofMinutes(minutes), policy.lockWindow()); + } + + @Test + @DisplayName("The production defaults are 5 attempts within a 15 minute window") + void productionDefaults() { + LoginLockPolicy policy = config.loginLockPolicy(new AuthPolicyProperties(5, 15)); + + assertEquals(5, policy.maxFailedAttempts()); + assertEquals(15, policy.lockWindow().toMinutes()); + } + + @ParameterizedTest(name = "a non positive maxFailedAttempts of {0} is refused at startup") + @CsvSource({"0", "-1"}) + void nonPositiveAttemptsAreRefused(int attempts) { + AuthPolicyProperties properties = new AuthPolicyProperties(attempts, 15); + + assertThrows(IllegalArgumentException.class, () -> config.loginLockPolicy(properties)); + } + + @Test + @DisplayName("A zero minute window produces a zero duration, disabling the sliding window") + void zeroMinuteWindow() { + LoginLockPolicy policy = config.loginLockPolicy(new AuthPolicyProperties(5, 0)); + + assertEquals(Duration.ZERO, policy.lockWindow()); + } + } + + @Nested + @DisplayName("JpaAuditingConfig") + class JpaAuditingConfigTest { + + @Test + @DisplayName("Auditing is enabled declaratively, which is what fills createdAt and updatedAt") + void auditingIsEnabled() { + assertNotNull(JpaAuditingConfig.class.getAnnotation(EnableJpaAuditing.class)); + } + + @Test + @DisplayName("The class is a Spring configuration, so the annotation is actually processed") + void isAConfigurationClass() { + assertNotNull(JpaAuditingConfig.class.getAnnotation(Configuration.class)); + } + + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigJwtDecoderTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigJwtDecoderTest.java new file mode 100644 index 0000000..32dc60e --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigJwtDecoderTest.java @@ -0,0 +1,171 @@ +package com.codefactory.bookingplatform.shared.config; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The JWT decoder is the single gate that turns a bearer string into an authenticated caller, so it + * is exercised against a real JWKS endpoint and real signatures rather than against a mock. + * + *

Four rules are pinned here, and all four are invisible to a test that only checks that the + * bean is a {@code NimbusJwtDecoder}: the two signature algorithms Supabase issues (ES256 today, + * RS256 for legacy projects) are both accepted, anything else is refused, expired tokens are + * refused, and tokens minted by a different issuer are refused.

+ */ +class SecurityConfigJwtDecoderTest { + + private static final String ISSUER = "https://demo.supabase.co/auth/v1"; + private static final String SUBJECT = "11111111-2222-3333-4444-555555555555"; + + private static HttpServer jwksServer; + private static ECKey ecKey; + private static RSAKey rsaKey; + private static String jwksUri; + + /** + * Built per test on purpose. A decoder built once in {@code @BeforeAll} is only ever attributed + * to whichever test happens to run first, which hides the bean factory from any per-test + * analysis; building it here means every rule below exercises the factory itself. + */ + private final JwtDecoder decoder = new SecurityConfig( + new SecurityProperties(ISSUER, jwksUri), new tools.jackson.databind.ObjectMapper()).jwtDecoder(); + + @BeforeAll + static void startJwksEndpoint() throws Exception { + ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec-1").generate(); + rsaKey = new RSAKeyGenerator(2048).keyID("rsa-1").generate(); + byte[] jwks = new JWKSet(java.util.List.of(ecKey.toPublicJWK(), rsaKey.toPublicJWK())) + .toString().getBytes(StandardCharsets.UTF_8); + + jwksServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + jwksServer.createContext("/jwks.json", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, jwks.length); + exchange.getResponseBody().write(jwks); + exchange.close(); + }); + jwksServer.start(); + + jwksUri = "http://127.0.0.1:" + jwksServer.getAddress().getPort() + "/jwks.json"; + } + + @AfterAll + static void stopJwksEndpoint() { + jwksServer.stop(0); + } + + private static JWTClaimsSet claims(String issuer, Instant expiresAt) { + return new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(SUBJECT) + .claim("email", "ana.perez@example.com") + .issueTime(Date.from(expiresAt.minusSeconds(3600))) + .expirationTime(Date.from(expiresAt)) + .build(); + } + + private static String signedWithEc(JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(ecKey.getKeyID()).type(JOSEObjectType.JWT).build(), + claims); + jwt.sign(new ECDSASigner(ecKey)); + return jwt.serialize(); + } + + private static String signedWithRsa(JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).type(JOSEObjectType.JWT).build(), + claims); + jwt.sign(new RSASSASigner(rsaKey)); + return jwt.serialize(); + } + + @Test + @DisplayName("An ES256 token signed by the project keys is accepted: that is what Supabase issues today") + void es256TokenIsAccepted() throws Exception { + Jwt jwt = decoder.decode(signedWithEc(claims(ISSUER, Instant.now().plusSeconds(3600)))); + + assertEquals(SUBJECT, jwt.getSubject()); + assertEquals("ana.perez@example.com", jwt.getClaimAsString("email")); + } + + @Test + @DisplayName("An RS256 token signed by the project keys is accepted: legacy Supabase projects still sign that way") + void rs256TokenIsAccepted() throws Exception { + Jwt jwt = decoder.decode(signedWithRsa(claims(ISSUER, Instant.now().plusSeconds(3600)))); + + assertEquals(SUBJECT, jwt.getSubject()); + } + + @Test + @DisplayName("An expired token is refused, so a leaked token stops working when it lapses") + void expiredTokenIsRefused() throws Exception { + String expired = signedWithEc(claims(ISSUER, Instant.now().minusSeconds(600))); + + assertThrows(JwtException.class, () -> decoder.decode(expired)); + } + + @Test + @DisplayName("An expired RS256 token is refused as well, so the second algorithm is validated too") + void expiredRsaTokenIsRefused() throws Exception { + String expired = signedWithRsa(claims(ISSUER, Instant.now().minusSeconds(600))); + + assertThrows(JwtException.class, () -> decoder.decode(expired)); + } + + @Test + @DisplayName("SECURITY: a token minted by another issuer is refused even if the signature checks out") + void foreignIssuerIsRefused() throws Exception { + String foreign = signedWithEc(claims("https://attacker.example.com/auth/v1", Instant.now().plusSeconds(3600))); + + assertThrows(JwtException.class, () -> decoder.decode(foreign)); + } + + @Test + @DisplayName("SECURITY: a token with no issuer claim at all is refused") + void missingIssuerIsRefused() throws Exception { + String noIssuer = signedWithEc(claims(null, Instant.now().plusSeconds(3600))); + + assertThrows(JwtException.class, () -> decoder.decode(noIssuer)); + } + + @Test + @DisplayName("SECURITY: an HS256 token is refused, so a caller cannot sign with a guessed shared secret") + void symmetricallySignedTokenIsRefused() throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.HS256).build(), + claims(ISSUER, Instant.now().plusSeconds(3600))); + jwt.sign(new MACSigner("0123456789012345678901234567890123456789".getBytes(StandardCharsets.UTF_8))); + String symmetric = jwt.serialize(); + + assertThrows(JwtException.class, () -> decoder.decode(symmetric)); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java new file mode 100644 index 0000000..05c3502 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java @@ -0,0 +1,260 @@ +package com.codefactory.bookingplatform.shared.config; + +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.test.util.ReflectionTestUtils; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the pieces of {@link SecurityConfig} that produce a response: + * the JWT decoder bean and the two ProblemDetail writers used when a request is + * unauthenticated (401) or not allowed (403). + */ +class SecurityConfigTest { + + private static final SecurityProperties PROPERTIES = new SecurityProperties( + "http://localhost:54321/auth/v1", + "http://localhost:54321/auth/v1/.well-known/jwks.json"); + + private SecurityConfig config; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + config = new SecurityConfig(PROPERTIES, new ObjectMapper()); + request = new MockHttpServletRequest("GET", "/api/v1/auth/me"); + request.setRequestURI("/api/v1/auth/me"); + response = new MockHttpServletResponse(); + MDC.clear(); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + private AuthenticationEntryPoint entryPoint() { + return ReflectionTestUtils.invokeMethod(config, "problemDetailEntryPoint"); + } + + private AccessDeniedHandler accessDeniedHandler() { + return ReflectionTestUtils.invokeMethod(config, "problemDetailAccessDeniedHandler"); + } + + @Test + @DisplayName("The JWT decoder bean is built from the configured JWKS endpoint") + void jwtDecoderIsBuilt() { + JwtDecoder decoder = config.jwtDecoder(); + + assertInstanceOf(NimbusJwtDecoder.class, decoder); + } + + @Test + @DisplayName("A missing token is answered 401") + void unauthenticatedRequestGets401() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(401, response.getStatus()); + } + + @Test + @DisplayName("The 401 answer is a ProblemDetail document") + void unauthenticatedRequestGetsProblemJson() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(MediaType.APPLICATION_PROBLEM_JSON_VALUE, response.getContentType()); + } + + @Test + @DisplayName("The 401 answer carries the AUTH_REQUIRED error code") + void unauthenticatedRequestCarriesTheErrorCode() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertTrue(response.getContentAsString().contains("AUTH_REQUIRED"), + "expected AUTH_REQUIRED in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("The 401 answer points at the requested path") + void unauthenticatedRequestCarriesTheInstance() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertTrue(response.getContentAsString().contains("/api/v1/auth/me"), + "expected the request URI in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("The 401 answer propagates the current trace id so the caller can report it") + void unauthenticatedRequestCarriesTheTraceId() throws Exception { + MDC.put("traceId", "trace-4711"); + + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertTrue(response.getContentAsString().contains("trace-4711"), + "expected the trace id in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("A request without a trace id in the MDC is still answered, with no trace id") + void unauthenticatedRequestWithoutTraceIdStillAnswers() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(401, response.getStatus()); + } + + @Test + @DisplayName("An authenticated but unauthorised request is answered 403") + void forbiddenRequestGets403() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertEquals(403, response.getStatus()); + } + + @Test + @DisplayName("The 403 answer carries the ACCESS_DENIED error code") + void forbiddenRequestCarriesTheErrorCode() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertTrue(response.getContentAsString().contains("ACCESS_DENIED"), + "expected ACCESS_DENIED in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("The 403 answer is a ProblemDetail document") + void forbiddenRequestGetsProblemJson() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertEquals(MediaType.APPLICATION_PROBLEM_JSON_VALUE, response.getContentType()); + } + + @Test + @DisplayName("SECURITY: neither error answer leaks the underlying exception message") + void errorAnswersDoNotLeakInternals() throws Exception { + accessDeniedHandler().handle(request, response, + new AccessDeniedException("role ROLE_ADMIN required on method ClientAdminService.delete")); + + assertTrue(!response.getContentAsString().contains("ClientAdminService"), + "the internal message leaked into the body: " + response.getContentAsString()); + } + + @Test + @DisplayName("The 401/403 type URIs do not depend on the JVM default locale (Turkish dotless-i trap)") + void errorTypeUrisAreLocaleIndependent() throws Exception { + java.util.Locale previous = java.util.Locale.getDefault(); + java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr-TR")); + try { + entryPoint().commence(request, response, new StubAuthenticationException()); + assertTrue(response.getContentAsString().contains("/errors/auth_required"), + "expected the ASCII type URI but got: " + response.getContentAsString()); + + MockHttpServletResponse forbidden = new MockHttpServletResponse(); + accessDeniedHandler().handle(request, forbidden, new AccessDeniedException("nope")); + assertTrue(forbidden.getContentAsString().contains("/errors/access_denied"), + "expected the ASCII type URI but got: " + forbidden.getContentAsString()); + } finally { + java.util.Locale.setDefault(previous); + } + } + + @Test + @DisplayName("The entry point is reused for both the resource server and the generic handling") + void entryPointIsAvailable() { + assertNotNull(entryPoint()); + } + + // ----------------------------------------------------------------- + // The body of the error answer, field by field. Asserting only that + // the payload "contains AUTH_REQUIRED" leaves detail, title and + // timestamp free to disappear without a single test noticing. + // ----------------------------------------------------------------- + + private JsonNode body() throws Exception { + String payload = response.getContentAsString(); + return new ObjectMapper().readTree(payload); + } + + @Test + @DisplayName("The 401 answer explains in its detail that authentication is required") + void unauthenticatedRequestCarriesTheDefaultDetail() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(ErrorCode.AUTH_REQUIRED.defaultMessage(), body().path("detail").asString(null)); + } + + @Test + @DisplayName("The 403 answer explains in its detail that the permissions are insufficient") + void forbiddenRequestCarriesTheDefaultDetail() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertEquals(ErrorCode.ACCESS_DENIED.defaultMessage(), body().path("detail").asString(null)); + } + + @Test + @DisplayName("The 401 answer is titled with the reason phrase of its status") + void unauthenticatedRequestCarriesTheTitle() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(HttpStatus.UNAUTHORIZED.getReasonPhrase(), body().path("title").asString(null)); + } + + @Test + @DisplayName("The 403 answer is titled with the reason phrase of its status") + void forbiddenRequestCarriesTheTitle() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertEquals(HttpStatus.FORBIDDEN.getReasonPhrase(), body().path("title").asString(null)); + } + + @Test + @DisplayName("The 401 answer is stamped with the moment it was produced, so it can be correlated with the logs") + void unauthenticatedRequestCarriesATimestamp() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + JsonNode timestamp = body().path("properties").path("timestamp"); + + assertFalse(timestamp.isMissingNode() || timestamp.isNull(), + "no timestamp in the body: " + response.getContentAsString()); + assertDoesNotThrow(() -> java.time.Instant.parse(timestamp.asString())); + } + + @Test + @DisplayName("The 403 answer is stamped with the moment it was produced") + void forbiddenRequestCarriesATimestamp() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + JsonNode timestamp = body().path("properties").path("timestamp"); + + assertFalse(timestamp.isMissingNode() || timestamp.isNull(), + "no timestamp in the body: " + response.getContentAsString()); + assertDoesNotThrow(() -> java.time.Instant.parse(timestamp.asString())); + } + + private static final class StubAuthenticationException extends AuthenticationException { + StubAuthenticationException() { + super("Full authentication is required to access this resource"); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java new file mode 100644 index 0000000..3eb0e90 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java @@ -0,0 +1,296 @@ +package com.codefactory.bookingplatform.shared.config; + +import com.codefactory.bookingplatform.auth.api.AuthController; +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import com.codefactory.bookingplatform.identity.api.RegistrationController; +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.RegistrationOutcome; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.shared.observability.TraceIdFilter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The security filter chain itself, exercised end to end through MockMvc with + * the real {@link SecurityConfig}: which endpoints are public, what an + * unauthenticated call gets back, and how the JWT is turned into authorities. + */ +@WebMvcTest(controllers = {AuthController.class, RegistrationController.class}) +@Import({SecurityConfig.class, SupabaseJwtAuthConverter.class, SecurityConfigWebTest.CorsForTest.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +@EnableConfigurationProperties(SecurityProperties.class) +@TestPropertySource(properties = { + "app.security.jwt-issuer=http://localhost:54321/auth/v1", + "app.security.jwks-uri=http://localhost:54321/auth/v1/.well-known/jwks.json"}) +class SecurityConfigWebTest { + + private static final String SUBJECT = "11111111-2222-3333-4444-555555555555"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @MockitoBean + private LoginUseCase loginUseCase; + + @MockitoBean + private LogoutUseCase logoutUseCase; + + @MockitoBean + private PasswordRecoveryUseCase passwordRecoveryUseCase; + + @MockitoBean + private PasswordResetUseCase passwordResetUseCase; + + @MockitoBean + private RegisterClientUseCase registerClientUseCase; + + @MockitoBean + private ResendVerificationUseCase resendVerificationUseCase; + + @MockitoBean + private ConfirmEmailUseCase confirmEmailUseCase; + + private void stubToken(String tokenValue, Map extraClaims) { + Jwt.Builder builder = Jwt.withTokenValue(tokenValue) + .header("alg", "ES256") + .subject(SUBJECT) + .claim("email", "ana.perez@example.com") + .issuedAt(Instant.now().minusSeconds(60)) + .expiresAt(Instant.now().plusSeconds(3600)); + extraClaims.forEach(builder::claim); + when(jwtDecoder.decode(tokenValue)).thenReturn(builder.build()); + } + + @ParameterizedTest(name = "{1} {0} is public and is not answered 401") + @CsvSource({ + "/api/v1/auth/login, POST", + "/api/v1/auth/password-recovery-requests, POST", + "/api/v1/auth/password-resets, POST", + "/api/v1/registrations, POST", + "/api/v1/registrations/verification-resends, POST", + "/api/v1/registrations/email-verifications, POST"}) + @DisplayName("The declared public endpoints are reachable without a token") + void publicEndpointsDoNotRequireAToken(String path, String method) throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("a", "r", "bearer", 3600L)); + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(UUID.fromString(SUBJECT), "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(UUID.fromString(SUBJECT), "ana.perez@example.com", + ClientStatus.ACTIVE)); + + mockMvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + @DisplayName("CSRF is disabled, so a POST without a CSRF token is not answered 403") + void csrfIsDisabledForStatelessApiCalls() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("a", "r", "bearer", 3600L)); + + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\", \"password\": \"Str0ng!Pass\"}")) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "{0} requires authentication and is answered 401") + @ValueSource(strings = {"/api/v1/auth/me"}) + @DisplayName("A protected endpoint without a token is answered 401 AUTH_REQUIRED") + void protectedEndpointWithoutTokenIs401(String path) throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("POST /logout without a token is answered 401, not 204") + void logoutWithoutTokenIs401() throws Exception { + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("The 401 answer is a ProblemDetail pointing at the requested path") + void unauthorizedAnswerIsAProblemDetail() throws Exception { + mockMvc.perform(get("/api/v1/auth/me")) + .andExpect(jsonPath("$.instance").value("/api/v1/auth/me")) + .andExpect(jsonPath("$.title").value("Unauthorized")) + .andExpect(jsonPath("$.type") + .value("https://bookingplatform.codefactory.com/errors/auth_required")); + } + + @Test + @DisplayName("An unauthenticated call still gets a trace id header it can quote in a ticket") + void unauthorizedAnswerCarriesTheTraceIdHeader() throws Exception { + mockMvc.perform(get("/api/v1/auth/me")) + .andExpect(header().exists(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("An unparsable bearer token is answered 401, never 500") + void invalidTokenIs401() throws Exception { + when(jwtDecoder.decode("garbage")).thenThrow(new BadJwtException("malformed")); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer garbage")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("A valid token reaches the endpoint and the role comes from app_metadata") + void validTokenIsAcceptedAndRoleComesFromAppMetadata() throws Exception { + stubToken("valid-token", Map.of("app_metadata", Map.of("role", "client"))); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer valid-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").value("CLIENT")); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: a role planted in user_metadata grants no role at all") + void roleInUserMetadataIsIgnoredEndToEnd() throws Exception { + Map claims = new HashMap<>(); + claims.put("user_metadata", Map.of("role", "admin")); + stubToken("tampered-token", claims); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer tampered-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").doesNotExist()); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: app_metadata wins over a tampered user_metadata") + void appMetadataWinsEndToEnd() throws Exception { + Map claims = new HashMap<>(); + claims.put("app_metadata", Map.of("role", "client")); + claims.put("user_metadata", Map.of("role", "admin")); + stubToken("mixed-token", claims); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer mixed-token")) + .andExpect(jsonPath("$.role").value("CLIENT")); + } + + @Test + @DisplayName("An authenticated logout goes through and answers 204") + void authenticatedLogoutAnswers204() throws Exception { + stubToken("valid-token", Map.of("app_metadata", Map.of("role", "client"))); + + mockMvc.perform(post("/api/v1/auth/logout").header(HttpHeaders.AUTHORIZATION, "Bearer valid-token")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("An unmapped path still requires authentication, so nothing is public by accident") + void unmappedPathsAreNotPublic() throws Exception { + mockMvc.perform(get("/api/v1/internal/whatever")) + .andExpect(status().isUnauthorized()); + } + + /** + * The application declares {@code http.cors(...)} but publishes no + * {@link CorsConfigurationSource} bean of its own, so in production the CORS + * step is inert. This bean is supplied here to prove that the filter chain + * does wire the CORS step up when a configuration exists. + */ + @TestConfiguration(proxyBeanMethods = false) + static class CorsForTest { + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cors = new CorsConfiguration(); + cors.addAllowedOrigin("https://app.example.com"); + cors.addAllowedMethod("*"); + cors.addAllowedHeader("*"); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cors); + return source; + } + } + + @Test + @DisplayName("The chain honours the application CORS configuration, so the browser front end is answered a preflight") + void corsPreflightIsAnswered() throws Exception { + mockMvc.perform(options("/api/v1/auth/login") + .header(HttpHeaders.ORIGIN, "https://app.example.com") + .header("Access-Control-Request-Method", "POST")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", "https://app.example.com")); + } + + @Test + @DisplayName("The API is stateless: not even an unauthenticated call leaves an HTTP session behind") + void noHttpSessionIsCreated() throws Exception { + MvcResult result = mockMvc.perform(get("/api/v1/auth/me")).andReturn(); + + assertNull(result.getRequest().getSession(false), "the request created an HTTP session"); + } + + @Test + @DisplayName("The 401 body explains that authentication is required and is stamped with the moment it happened") + void unauthorizedAnswerCarriesDetailAndTimestamp() throws Exception { + mockMvc.perform(get("/api/v1/auth/me")) + .andExpect(jsonPath("$.detail").value("Authentication is required")) + .andExpect(jsonPath("$.timestamp").isString()); + } + + @Test + @DisplayName("A token rejected by the decoder is answered with the platform ProblemDetail, not with the Spring default") + void invalidTokenAnswerIsOurProblemDetail() throws Exception { + when(jwtDecoder.decode("garbage")).thenThrow(new BadJwtException("malformed")); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer garbage")) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")) + .andExpect(jsonPath("$.detail").value("Authentication is required")) + .andExpect(jsonPath("$.instance").value("/api/v1/auth/me")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java new file mode 100644 index 0000000..be5a56f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java @@ -0,0 +1,245 @@ +package com.codefactory.bookingplatform.shared.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Security rule under test: the application role is taken from the + * server-managed app_metadata claim only. Anything else, including the + * client-editable user_metadata claim, must grant nothing. + * + * White box: the converter has three decision points (claim is a Map, role is a + * String, role is not blank); each one is exercised on both outcomes. + */ +class SupabaseJwtAuthConverterTest { + + private final SupabaseJwtAuthConverter converter = new SupabaseJwtAuthConverter(); + + private static Jwt jwtWithClaims(Map claims) { + Jwt.Builder builder = Jwt.withTokenValue("token") + .header("alg", "ES256") + .subject("11111111-2222-3333-4444-555555555555") + .issuedAt(Instant.parse("2026-09-22T10:00:00Z")) + .expiresAt(Instant.parse("2026-09-22T11:00:00Z")); + claims.forEach(builder::claim); + return builder.build(); + } + + private static Jwt jwtWithAppMetadata(Object appMetadata) { + Map claims = new HashMap<>(); + claims.put("app_metadata", appMetadata); + return jwtWithClaims(claims); + } + + private static List authorityNames(Collection authorities) { + return authorities.stream().map(GrantedAuthority::getAuthority).toList(); + } + + @ParameterizedTest(name = "app_metadata.role = [{0}] grants [{1}]") + @CsvSource({ + "client, ROLE_CLIENT", + "CLIENT, ROLE_CLIENT", + "Provider, ROLE_PROVIDER", + "aDmIn, ROLE_ADMIN", + "a, ROLE_A"}) + @DisplayName("A role in app_metadata becomes ROLE_ plus its upper case name") + void roleFromAppMetadataIsUpperCasedAndPrefixed(String role, String expectedAuthority) { + Collection authorities = converter.convert(jwtWithAppMetadata(Map.of("role", role))); + + assertEquals(List.of(expectedAuthority), authorityNames(authorities)); + } + + @ParameterizedTest(name = "under the Turkish locale [{0}] still grants [{1}]") + @CsvSource({ + "admin, ROLE_ADMIN", + "client, ROLE_CLIENT", + "i, ROLE_I"}) + @DisplayName("The authority name does not depend on the JVM default locale (Turkish dotted-I trap)") + void roleUpperCasingIsLocaleIndependent(String role, String expectedAuthority) { + Locale previous = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + try { + Collection authorities = converter.convert(jwtWithAppMetadata(Map.of("role", role))); + + assertEquals(List.of(expectedAuthority), authorityNames(authorities)); + } finally { + Locale.setDefault(previous); + } + } + + @Test + @DisplayName("Exactly one authority is granted, never a duplicate") + void grantsASingleAuthority() { + assertEquals(1, converter.convert(jwtWithAppMetadata(Map.of("role", "client"))).size()); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: a role planted in user_metadata is ignored") + void roleInUserMetadataIsIgnored() { + Map claims = new HashMap<>(); + claims.put("user_metadata", Map.of("role", "admin")); + + Collection authorities = converter.convert(jwtWithClaims(claims)); + + assertEquals(List.of(), authorityNames(authorities)); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: user_metadata never overrides the app_metadata role") + void userMetadataDoesNotOverrideAppMetadata() { + Map claims = new HashMap<>(); + claims.put("app_metadata", Map.of("role", "client")); + claims.put("user_metadata", Map.of("role", "admin")); + + Collection authorities = converter.convert(jwtWithClaims(claims)); + + assertEquals(List.of("ROLE_CLIENT"), authorityNames(authorities)); + } + + @Test + @DisplayName("An absent app_metadata claim grants nothing") + void missingAppMetadataGrantsNothing() { + Map claims = new HashMap<>(); + claims.put("email", "ana@example.com"); + + assertTrue(converter.convert(jwtWithClaims(claims)).isEmpty()); + } + + @Test + @DisplayName("An app_metadata claim that is not a map grants nothing") + void nonMapAppMetadataGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata("role=admin")).isEmpty()); + } + + @Test + @DisplayName("An app_metadata list grants nothing") + void listAppMetadataGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata(List.of("admin"))).isEmpty()); + } + + @Test + @DisplayName("An app_metadata map without a role key grants nothing") + void appMetadataWithoutRoleGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata(Map.of("provider", "email"))).isEmpty()); + } + + @Test + @DisplayName("An empty app_metadata map grants nothing") + void emptyAppMetadataGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata(Map.of())).isEmpty()); + } + + @ParameterizedTest(name = "a blank role [{0}] grants nothing") + @ValueSource(strings = {"", " ", " ", "\t", "\n"}) + void blankRoleGrantsNothing(String role) { + Map metadata = new HashMap<>(); + metadata.put("role", role); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A null role grants nothing") + void nullRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", null); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A numeric role is not a String and grants nothing") + void numericRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", 42); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A role given as a list is not a String and grants nothing") + void listRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", List.of("admin")); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A boolean role is not a String and grants nothing") + void booleanRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", Boolean.TRUE); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("The returned collection is immutable, so no caller can add an authority") + void returnedAuthoritiesAreImmutable() { + Collection authorities = converter.convert(jwtWithAppMetadata(Map.of("role", "client"))); + + org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class, + () -> authorities.add(() -> "ROLE_ADMIN")); + } + + /** The framework adds its own factor authorities (FACTOR_BEARER); only the roles matter here. */ + private static List roleNames(Collection a) { + return a.stream().map(org.springframework.security.core.GrantedAuthority::getAuthority) + .filter(name -> name.startsWith("ROLE_")) + .toList(); + } + + @Test + @DisplayName("toAuthenticationConverter wires the authorities converter into the Spring Security token") + void authenticationConverterUsesTheAuthoritiesConverter() { + JwtAuthenticationConverter authenticationConverter = + SupabaseJwtAuthConverter.toAuthenticationConverter(converter); + + JwtAuthenticationToken token = (JwtAuthenticationToken) authenticationConverter + .convert(jwtWithAppMetadata(Map.of("role", "admin"))); + + assertNotNull(token); + assertEquals(List.of("ROLE_ADMIN"), roleNames(token.getAuthorities())); + } + + @Test + @DisplayName("toAuthenticationConverter grants no role when the role sits in user_metadata") + void authenticationConverterIgnoresUserMetadata() { + Map claims = new HashMap<>(); + claims.put("user_metadata", Map.of("role", "admin")); + JwtAuthenticationConverter authenticationConverter = + SupabaseJwtAuthConverter.toAuthenticationConverter(converter); + + JwtAuthenticationToken token = (JwtAuthenticationToken) authenticationConverter.convert(jwtWithClaims(claims)); + + assertNotNull(token); + assertEquals(List.of(), roleNames(token.getAuthorities())); + } + + @Test + @DisplayName("The public constants keep the contract the rest of the code relies on") + void constantsAreStable() { + assertEquals("app_metadata", SupabaseJwtAuthConverter.APP_METADATA_CLAIM); + assertEquals("role", SupabaseJwtAuthConverter.ROLE_KEY); + assertEquals("ROLE_", SupabaseJwtAuthConverter.ROLE_PREFIX); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java new file mode 100644 index 0000000..1e69076 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java @@ -0,0 +1,268 @@ +package com.codefactory.bookingplatform.shared.error; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Carrier for a rejected business rule. It pairs an {@link ErrorCode} with a message and an + * optional detail map that the exception handler publishes to the client, so the detail map must + * be a defensive, unmodifiable copy: it crosses layers and is serialised. + * + *

Black box: one partition per constructor plus the {@code of} factory. White box: the three + * constructors all funnel into the canonical one, so the copy and the wrapping are exercised for + * every entry point.

+ */ +class BusinessExceptionTest { + + @Nested + @DisplayName("Constructors") + class Constructors { + + @Test + @DisplayName("The code-only constructor falls back to the default message of the error code") + void codeOnlyUsesTheDefaultMessage() { + BusinessException ex = new BusinessException(ErrorCode.DUPLICATE_EMAIL); + assertEquals(ErrorCode.DUPLICATE_EMAIL.defaultMessage(), ex.getMessage()); + } + + @Test + @DisplayName("The code-only constructor keeps the error code") + void codeOnlyKeepsTheErrorCode() { + assertEquals(ErrorCode.DUPLICATE_EMAIL, new BusinessException(ErrorCode.DUPLICATE_EMAIL).errorCode()); + } + + @Test + @DisplayName("The code-only constructor leaves the detail map empty, so no extra field reaches the client") + void codeOnlyHasNoDetails() { + assertTrue(new BusinessException(ErrorCode.DUPLICATE_EMAIL).details().isEmpty()); + } + + @Test + @DisplayName("The code-and-message constructor overrides the default message") + void codeAndMessageOverridesTheDefaultMessage() { + BusinessException ex = new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, "too short"); + assertEquals("too short", ex.getMessage()); + } + + @Test + @DisplayName("The code-and-message constructor leaves the detail map empty") + void codeAndMessageHasNoDetails() { + assertTrue(new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, "too short").details().isEmpty()); + } + + @Test + @DisplayName("The full constructor keeps the code, the message and the details together") + void fullConstructorKeepsEverything() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid payload", Map.of("email", "must not be blank")); + assertEquals(ErrorCode.VALIDATION_ERROR, ex.errorCode()); + assertEquals("invalid payload", ex.getMessage()); + assertEquals(Map.of("email", "must not be blank"), ex.details()); + } + + @Test + @DisplayName("The full constructor preserves the insertion order of the details, which is the order shown to the client") + void fullConstructorPreservesDetailOrder() { + Map ordered = new LinkedHashMap<>(); + ordered.put("first", "1"); + ordered.put("second", "2"); + ordered.put("third", "3"); + BusinessException ex = new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", ordered); + assertIterableEquals(List.of("first", "second", "third"), ex.details().keySet()); + } + + @Test + @DisplayName("An explicitly null message is kept as null instead of being replaced by the default one") + void nullMessageIsNotReplaced() { + assertNull(new BusinessException(ErrorCode.ACCESS_DENIED, null).getMessage()); + } + + @Test + @DisplayName("An empty detail map produces an empty detail map, not a null one") + void emptyDetailsStayEmpty() { + BusinessException ex = new BusinessException(ErrorCode.ACCESS_DENIED, "denied", Map.of()); + assertTrue(ex.details().isEmpty()); + } + + @ParameterizedTest(name = "{0} can be carried") + @EnumSource(ErrorCode.class) + @DisplayName("Any error code in the catalogue can be carried by the exception") + void anyErrorCodeCanBeCarried(ErrorCode code) { + assertEquals(code, new BusinessException(code).errorCode()); + } + + @Test + @DisplayName("The exception is unchecked so use cases can reject a rule without declaring it") + void exceptionIsUnchecked() { + assertInstanceOf(RuntimeException.class, new BusinessException(ErrorCode.ACCESS_DENIED)); + } + } + + @Nested + @DisplayName("Java serialisation") + class Serialisation { + + private static BusinessException roundTrip(BusinessException original) throws Exception { + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + try (java.io.ObjectOutputStream out = new java.io.ObjectOutputStream(bytes)) { + out.writeObject(original); + } + try (java.io.ObjectInputStream in = + new java.io.ObjectInputStream(new java.io.ByteArrayInputStream(bytes.toByteArray()))) { + return (BusinessException) in.readObject(); + } + } + + @Test + @DisplayName("The exception declares an explicit serialVersionUID so a redeploy cannot change it") + void declaresAnExplicitSerialVersionUid() throws Exception { + java.lang.reflect.Field field = BusinessException.class.getDeclaredField("serialVersionUID"); + field.setAccessible(true); + assertEquals(1L, field.getLong(null)); + } + + @Test + @DisplayName("A serialised rejection keeps its error code and message") + void roundTripKeepsCodeAndMessage() throws Exception { + BusinessException restored = roundTrip( + new BusinessException(ErrorCode.DUPLICATE_DOCUMENT, "document already used")); + + assertEquals(ErrorCode.DUPLICATE_DOCUMENT, restored.errorCode()); + assertEquals("document already used", restored.getMessage()); + } + + @Test + @DisplayName("A serialised rejection keeps its details: the detail map is not transient") + void roundTripKeepsTheDetails() throws Exception { + Map ordered = new LinkedHashMap<>(); + ordered.put("first", "1"); + ordered.put("second", "2"); + + BusinessException restored = roundTrip( + new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", ordered)); + + assertEquals(ordered, restored.details()); + assertIterableEquals(List.of("first", "second"), restored.details().keySet()); + } + + @Test + @DisplayName("The details published by a deserialised rejection are still unmodifiable") + void roundTripKeepsTheDetailsUnmodifiable() throws Exception { + BusinessException restored = roundTrip( + new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "blank"))); + + assertThrows(UnsupportedOperationException.class, () -> restored.details().put("k", "v")); + } + } + + @Nested + @DisplayName("of factory") + class OfFactory { + + @Test + @DisplayName("The factory builds an exception with exactly one detail entry") + void factoryBuildsASingleDetailEntry() { + BusinessException ex = BusinessException.of( + ErrorCode.DUPLICATE_DOCUMENT, "document already used", "documentNumber", "1017245896"); + assertEquals(Map.of("documentNumber", "1017245896"), ex.details()); + } + + @Test + @DisplayName("The factory keeps the error code and the message it was given") + void factoryKeepsCodeAndMessage() { + BusinessException ex = BusinessException.of( + ErrorCode.DUPLICATE_DOCUMENT, "document already used", "documentNumber", "1017245896"); + assertEquals(ErrorCode.DUPLICATE_DOCUMENT, ex.errorCode()); + assertEquals("document already used", ex.getMessage()); + } + + @Test + @DisplayName("The detail map built by the factory is unmodifiable like any other") + void factoryDetailsAreUnmodifiable() { + BusinessException ex = BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "no such booking", "id", "42"); + assertThrows(UnsupportedOperationException.class, () -> ex.details().put("other", "x")); + } + + @Test + @DisplayName("A null detail key is rejected, because the factory relies on an immutable map") + void factoryRejectsANullDetailKey() { + assertThrows(NullPointerException.class, + () -> BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "missing", null, "42")); + } + } + + @Nested + @DisplayName("Detail map immutability") + class DetailMapImmutability { + + @Test + @DisplayName("Adding an entry to the published details is rejected, so the exception cannot be altered downstream") + void detailsRejectInsertion() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "must not be blank")); + assertThrows(UnsupportedOperationException.class, () -> ex.details().put("password", "too weak")); + } + + @Test + @DisplayName("Removing an entry from the published details is rejected") + void detailsRejectRemoval() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "must not be blank")); + assertThrows(UnsupportedOperationException.class, () -> ex.details().remove("email")); + } + + @Test + @DisplayName("Clearing the published details is rejected") + void detailsRejectClear() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "must not be blank")); + assertThrows(UnsupportedOperationException.class, () -> ex.details().clear()); + } + + @Test + @DisplayName("An empty detail map is unmodifiable too") + void emptyDetailsAreUnmodifiable() { + BusinessException ex = new BusinessException(ErrorCode.ACCESS_DENIED); + assertThrows(UnsupportedOperationException.class, () -> ex.details().put("k", "v")); + } + + @Test + @DisplayName("The exception copies the caller map, so adding to the original afterwards does not leak into the exception") + void mutatingTheSourceMapDoesNotAffectTheException() { + Map source = new HashMap<>(); + source.put("email", "must not be blank"); + BusinessException ex = new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", source); + + source.put("password", "too weak"); + + assertEquals(Map.of("email", "must not be blank"), ex.details()); + } + + @Test + @DisplayName("Clearing the caller map afterwards does not empty the details already carried by the exception") + void clearingTheSourceMapDoesNotEmptyTheException() { + Map source = new HashMap<>(); + source.put("email", "must not be blank"); + BusinessException ex = new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", source); + + source.clear(); + + assertEquals(1, ex.details().size()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java new file mode 100644 index 0000000..9d5330f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java @@ -0,0 +1,50 @@ +package com.codefactory.bookingplatform.shared.error; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.HttpStatus; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * El catálogo de errores es contrato público: cada código viaja al cliente dentro del + * ProblemDetail y lleva asociado un estado HTTP del que dependen los consumidores de la API. + * + *

Se prueba como una tabla de decisión, que es lo que es. Cambiar el estado de un código + * rompe a quien lo consume, así que la tabla está aquí para que ese cambio no pase inadvertido.

+ */ +class ErrorCodeTest { + + @ParameterizedTest(name = "{0} -> {1}") + @CsvSource({ + "VALIDATION_ERROR, BAD_REQUEST", + "MINOR_NOT_ALLOWED, BAD_REQUEST", + "PASSWORD_TOO_WEAK, BAD_REQUEST", + "VERIFICATION_TOKEN_INVALID, BAD_REQUEST", + "AUTH_REQUIRED, UNAUTHORIZED", + "INVALID_CREDENTIALS, UNAUTHORIZED", + "AUTH_TOKEN_INVALID, UNAUTHORIZED", + "RESOURCE_NOT_FOUND, NOT_FOUND", + "EMAIL_NOT_CONFIRMED, FORBIDDEN", + "ACCESS_DENIED, FORBIDDEN", + "DUPLICATE_EMAIL, CONFLICT", + "DUPLICATE_DOCUMENT, CONFLICT", + "RATE_LIMITED, TOO_MANY_REQUESTS", + "ACCOUNT_LOCKED, TOO_MANY_REQUESTS", + "UPSTREAM_AUTH_ERROR, BAD_GATEWAY", + "INTERNAL_ERROR, INTERNAL_SERVER_ERROR" + }) + @DisplayName("Each error code maps to the HTTP status its semantics demand") + void statusMatchesTheSemanticsOfTheCode(ErrorCode code, HttpStatus expectedStatus) { + assertEquals(expectedStatus, code.status()); + } + + @Test + @DisplayName("The table above covers the whole catalogue, so a new code cannot slip through untested") + void decisionTableCoversTheWholeCatalogue() { + assertEquals(16, ErrorCode.values().length, + "A code was added or removed: update the status decision table in this test"); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..dae884a --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java @@ -0,0 +1,634 @@ +package com.codefactory.bookingplatform.shared.error; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.MDC; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +/** + * The global advice is the only place that turns an exception into the RFC 7807 body the client + * sees, so every handler is invoked directly (no Spring context) and the whole ProblemDetail is + * checked: status, title, type, instance, errorCode, traceId and the optional details. + * + *

Black box: one partition per {@code @ExceptionHandler}, plus the traced / untraced partition + * of the MDC. White box: both branches of {@code details().isEmpty()} in {@code handleBusiness}, + * the {@code instanceof} chain and the {@code getCodes()} guard in {@code handleValidation}, and + * both outcomes of {@code is5xxServerError()} in the private {@code build} method.

+ */ +class GlobalExceptionHandlerTest { + + private static final String TRACE_ID = "0af7651916cd43dd8448eb211c80319c"; + private static final String REQUEST_URI = "/api/v1/auth/login"; + private static final String TYPE_PREFIX = "https://bookingplatform.codefactory.com/errors/"; + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + MDC.clear(); + request = new MockHttpServletRequest("POST", REQUEST_URI); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + private static Map propertiesOf(ProblemDetail problem) { + Map properties = problem.getProperties(); + assertNotNull(properties, "The handler must always publish the errorCode and traceId properties"); + return properties; + } + + /** Placeholder controller method used to build a real {@link MethodParameter}. */ + @SuppressWarnings("unused") + private void controllerMethod(String payload) { + // never invoked; only its signature is needed + } + + private MethodArgumentNotValidException methodArgumentNotValid(List errors) throws Exception { + Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("controllerMethod", String.class); + MethodParameter parameter = new MethodParameter(method, 0); + BeanPropertyBindingResult binding = new BeanPropertyBindingResult(new Object(), "payload"); + errors.forEach(binding::addError); + return new MethodArgumentNotValidException(parameter, binding); + } + + private HandlerMethodValidationException handlerMethodValidation(List errors) { + HandlerMethodValidationException ex = mock(HandlerMethodValidationException.class); + doReturn(errors).when(ex).getAllErrors(); + return ex; + } + + // ---------------------------------------------------------------------------------------- + // handleBusiness + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Business rule rejections") + class BusinessRuleRejections { + + @Test + @DisplayName("A business rejection answers with the HTTP status declared by its error code") + void statusComesFromTheErrorCode() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(HttpStatus.CONFLICT.value(), problem.getStatus()); + } + + @Test + @DisplayName("A business rejection publishes the machine-readable error code so the client can branch on it") + void publishesTheErrorCode() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals("DUPLICATE_EMAIL", propertiesOf(problem).get("errorCode")); + } + + @Test + @DisplayName("A business rejection points to the documentation page of its error code") + void publishesTheTypeUri() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(URI.create(TYPE_PREFIX + "duplicate_email"), problem.getType()); + } + + @ParameterizedTest(name = "{0} keeps its type URI under the Turkish locale") + @EnumSource(ErrorCode.class) + @DisplayName("The type URI does not depend on the JVM default locale (Turkish dotless-i trap)") + void typeUriIsLocaleIndependent(ErrorCode code) { + java.util.Locale previous = java.util.Locale.getDefault(); + java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr-TR")); + try { + ProblemDetail problem = handler.handleBusiness(new BusinessException(code), request); + + assertEquals(URI.create(TYPE_PREFIX + code.name().toLowerCase(java.util.Locale.ROOT)), + problem.getType()); + } finally { + java.util.Locale.setDefault(previous); + } + } + + @Test + @DisplayName("A business rejection points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(URI.create(REQUEST_URI), problem.getInstance()); + } + + @Test + @DisplayName("A business rejection carries the message of the exception as the human-readable detail") + void detailIsTheExceptionMessage() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL, "ana@example.com is already registered"), request); + assertEquals("ana@example.com is already registered", problem.getDetail()); + } + + @Test + @DisplayName("A 4xx business rejection uses the reason phrase of its own status as the title") + void titleIsTheReasonPhraseForClientErrors() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(HttpStatus.CONFLICT.getReasonPhrase(), problem.getTitle()); + } + + @Test + @DisplayName("A 5xx business rejection is titled as an internal error so the upstream failure is not disclosed") + void titleIsMaskedForServerErrors() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR), request); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), problem.getTitle()); + } + + @Test + @DisplayName("The details property is published when the business rejection carries details") + void detailsArePublishedWhenPresent() { + BusinessException ex = BusinessException.of( + ErrorCode.DUPLICATE_DOCUMENT, "already used", "documentNumber", "1017245896"); + ProblemDetail problem = handler.handleBusiness(ex, request); + assertEquals(Map.of("documentNumber", "1017245896"), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("The details property is omitted when the business rejection carries no details, so the body stays clean") + void detailsAreOmittedWhenEmpty() { + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + assertFalse(propertiesOf(problem).containsKey("details"), + () -> "Expected no details property, got " + propertiesOf(problem)); + } + + @ParameterizedTest(name = "{0} keeps its status and its name in the body") + @EnumSource(ErrorCode.class) + @DisplayName("Every error code in the catalogue is translated into its own status and name") + void everyErrorCodeIsTranslated(ErrorCode code) { + ProblemDetail problem = handler.handleBusiness(new BusinessException(code), request); + assertEquals(code.status().value(), problem.getStatus()); + assertEquals(code.name(), propertiesOf(problem).get("errorCode")); + } + } + + // ---------------------------------------------------------------------------------------- + // handleValidation + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Bean validation failures") + class BeanValidationFailures { + + @Test + @DisplayName("A body validation failure answers 400 with the validation error code") + void bodyValidationAnswersBadRequest() throws Exception { + ProblemDetail problem = handler.handleValidation( + methodArgumentNotValid(List.of(new FieldError("payload", "email", "must not be blank"))), request); + assertEquals(HttpStatus.BAD_REQUEST.value(), problem.getStatus()); + assertEquals("VALIDATION_ERROR", propertiesOf(problem).get("errorCode")); + } + + @Test + @DisplayName("A body validation failure lists each rejected field with its message") + void fieldErrorsAreListedByFieldName() throws Exception { + MethodArgumentNotValidException ex = methodArgumentNotValid(List.of( + new FieldError("payload", "email", "must not be blank"), + new FieldError("payload", "password", "size must be between 8 and 72"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("email", "must not be blank", "password", "size must be between 8 and 72"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A body validation failure also lists cross-field errors under the name of the validated object") + void globalErrorsAreListedByObjectName() throws Exception { + MethodArgumentNotValidException ex = methodArgumentNotValid(List.of( + new ObjectError("payload", "password and confirmation do not match"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("payload", "password and confirmation do not match"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("Field errors and cross-field errors are reported together in the same details map") + void fieldAndGlobalErrorsAreReportedTogether() throws Exception { + MethodArgumentNotValidException ex = methodArgumentNotValid(List.of( + new FieldError("payload", "email", "must not be blank"), + new ObjectError("payload", "password and confirmation do not match"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("email", "must not be blank", + "payload", "password and confirmation do not match"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure keys each error by its first resolvable code") + void parameterErrorsAreKeyedByTheirFirstCode() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable(new String[]{"Size.name"}, null, "size must be between 1 and 60"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("Size.name", "size must be between 1 and 60"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure without codes falls back to the positional argument name") + void parameterErrorsWithoutCodesFallBackToThePosition() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable((String[]) null, null, "must not be null"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("arg0", "must not be null"), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure with an empty code array also falls back to the positional name") + void parameterErrorsWithEmptyCodesFallBackToThePosition() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable(new String[0], null, "must not be null"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("arg0", "must not be null"), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("The positional fallback advances with each error, so two unnamed errors do not overwrite each other") + void positionalFallbackAdvancesPerError() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable((String[]) null, null, "must not be null"), + new DefaultMessageSourceResolvable((String[]) null, null, "must be positive"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("arg0", "must not be null", "arg1", "must be positive"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure mixing named and unnamed errors keeps the index aligned with the argument position") + void namedAndUnnamedParameterErrorsCoexist() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable(new String[]{"Min.page"}, null, "must be at least 0"), + new DefaultMessageSourceResolvable((String[]) null, null, "must not be null"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("Min.page", "must be at least 0", "arg1", "must not be null"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A validation failure with no collected errors still answers 400 with an empty details map") + void emptyValidationStillAnswersBadRequest() { + ProblemDetail problem = handler.handleValidation(handlerMethodValidation(List.of()), request); + assertEquals(Map.of(), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("An exception of neither validation type is still reported as a validation error with no details") + void unknownValidationTypeYieldsEmptyDetails() { + ProblemDetail problem = handler.handleValidation(new IllegalStateException("not a validation error"), request); + assertEquals(HttpStatus.BAD_REQUEST.value(), problem.getStatus()); + assertEquals(Map.of(), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A validation failure never echoes the raw exception message, only the generic validation detail") + void detailIsTheGenericValidationMessage() throws Exception { + ProblemDetail problem = handler.handleValidation( + methodArgumentNotValid(List.of(new FieldError("payload", "email", "must not be blank"))), request); + assertEquals(ErrorCode.VALIDATION_ERROR.defaultMessage(), problem.getDetail()); + } + + @Test + @DisplayName("A validation failure points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + ProblemDetail problem = handler.handleValidation(handlerMethodValidation(List.of()), request); + assertEquals(URI.create(REQUEST_URI), problem.getInstance()); + } + } + + // ---------------------------------------------------------------------------------------- + // handleUnreadable + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Malformed request body") + class MalformedRequestBody { + + private final HttpMessageNotReadableException unreadable = mock(HttpMessageNotReadableException.class); + + @Test + @DisplayName("An unparsable body answers 400, because the client sent something the API cannot read") + void answersBadRequest() { + assertEquals(HttpStatus.BAD_REQUEST.value(), handler.handleUnreadable(unreadable, request).getStatus()); + } + + @Test + @DisplayName("An unparsable body is reported under the validation error code") + void reportsTheValidationErrorCode() { + assertEquals("VALIDATION_ERROR", + propertiesOf(handler.handleUnreadable(unreadable, request)).get("errorCode")); + } + + @Test + @DisplayName("An unparsable body gets a fixed detail that does not disclose the parser internals") + void detailIsAFixedMessage() { + assertEquals("Malformed request body", handler.handleUnreadable(unreadable, request).getDetail()); + } + + @Test + @DisplayName("An unparsable body points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + assertEquals(URI.create(REQUEST_URI), handler.handleUnreadable(unreadable, request).getInstance()); + } + + @Test + @DisplayName("An unparsable body publishes no details property") + void publishesNoDetails() { + assertFalse(propertiesOf(handler.handleUnreadable(unreadable, request)).containsKey("details")); + } + } + + // ---------------------------------------------------------------------------------------- + // handleAccessDenied + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Access denied") + class AccessDenied { + + private final AccessDeniedException denied = new AccessDeniedException("Access is denied to /admin/users"); + + @Test + @DisplayName("A denied authorization answers 403") + void answersForbidden() { + assertEquals(HttpStatus.FORBIDDEN.value(), handler.handleAccessDenied(denied, request).getStatus()); + } + + @Test + @DisplayName("A denied authorization is reported under the access denied error code") + void reportsTheAccessDeniedErrorCode() { + assertEquals("ACCESS_DENIED", + propertiesOf(handler.handleAccessDenied(denied, request)).get("errorCode")); + } + + @Test + @DisplayName("A denied authorization answers with the generic message, never with the resource the caller was probing") + void detailDoesNotDiscloseTheProbedResource() { + ProblemDetail problem = handler.handleAccessDenied(denied, request); + assertEquals(ErrorCode.ACCESS_DENIED.defaultMessage(), problem.getDetail()); + assertFalse(String.valueOf(problem.getDetail()).contains("/admin/users")); + } + + @Test + @DisplayName("A denied authorization points to the access denied documentation page") + void publishesTheTypeUri() { + assertEquals(URI.create(TYPE_PREFIX + "access_denied"), + handler.handleAccessDenied(denied, request).getType()); + } + + @Test + @DisplayName("A denied authorization points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + assertEquals(URI.create(REQUEST_URI), handler.handleAccessDenied(denied, request).getInstance()); + } + } + + // ---------------------------------------------------------------------------------------- + // handleUnexpected - security critical: nothing internal may reach the client + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Unexpected failures") + class UnexpectedFailures { + + private static final String SECRET = "jdbc:postgresql://10.0.0.5:5432/booking?password=hunter2"; + + private final RuntimeException leaky = new IllegalStateException("connection refused for " + SECRET); + + @Test + @DisplayName("An unexpected failure answers 500") + void answersInternalServerError() { + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), + handler.handleUnexpected(leaky, request).getStatus()); + } + + @Test + @DisplayName("An unexpected failure is reported under the internal error code") + void reportsTheInternalErrorCode() { + assertEquals("INTERNAL_ERROR", + propertiesOf(handler.handleUnexpected(leaky, request)).get("errorCode")); + } + + @Test + @DisplayName("An unexpected failure is titled as an internal server error, masking the concrete exception") + void titleIsMaskedAsInternalError() { + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), + handler.handleUnexpected(leaky, request).getTitle()); + } + + @Test + @DisplayName("An unexpected failure answers with the generic internal message, not with the exception message") + void detailIsTheGenericInternalMessage() { + assertEquals(ErrorCode.INTERNAL_ERROR.defaultMessage(), + handler.handleUnexpected(leaky, request).getDetail()); + } + + @Test + @DisplayName("The connection string of the failing exception never reaches the response body") + void doesNotLeakTheExceptionMessage() { + ProblemDetail problem = handler.handleUnexpected(leaky, request); + String rendered = problem.getTitle() + "|" + problem.getDetail() + "|" + problem.getType() + + "|" + problem.getInstance() + "|" + propertiesOf(problem); + assertFalse(rendered.contains(SECRET), + () -> "The internal connection string leaked into the response: " + rendered); + } + + @Test + @DisplayName("The class name of the failing exception never reaches the response body either") + void doesNotLeakTheExceptionType() { + ProblemDetail problem = handler.handleUnexpected(leaky, request); + String rendered = problem.getTitle() + "|" + problem.getDetail() + "|" + problem.getType() + + "|" + problem.getInstance() + "|" + propertiesOf(problem); + assertFalse(rendered.contains("IllegalStateException"), + () -> "The exception type leaked into the response: " + rendered); + } + + @Test + @DisplayName("No stack trace element reaches the response body") + void doesNotLeakTheStackTrace() { + ProblemDetail problem = handler.handleUnexpected(leaky, request); + assertFalse(propertiesOf(problem).containsKey("stackTrace")); + assertFalse(propertiesOf(problem).containsKey("exception")); + } + + @Test + @DisplayName("A failure whose cause carries the secret does not leak it either") + void doesNotLeakTheCauseMessage() { + RuntimeException wrapped = new RuntimeException("wrapper", new IllegalStateException(SECRET)); + ProblemDetail problem = handler.handleUnexpected(wrapped, request); + assertFalse(String.valueOf(propertiesOf(problem)).contains(SECRET)); + } + + @Test + @DisplayName("An unexpected failure with no message at all is still answered with the generic internal message") + void handlesAnExceptionWithoutMessage() { + assertEquals(ErrorCode.INTERNAL_ERROR.defaultMessage(), + handler.handleUnexpected(new RuntimeException(), request).getDetail()); + } + + @Test + @DisplayName("An unexpected failure points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + assertEquals(URI.create(REQUEST_URI), handler.handleUnexpected(leaky, request).getInstance()); + } + + @Test + @DisplayName("An unexpected failure points to the internal error documentation page") + void publishesTheTypeUri() { + assertEquals(URI.create(TYPE_PREFIX + "internal_error"), + handler.handleUnexpected(leaky, request).getType()); + } + } + + // ---------------------------------------------------------------------------------------- + // Trace id taken from the MDC + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Trace correlation") + class TraceCorrelation { + + @Test + @DisplayName("The trace id of the current request is copied from the MDC into the body so support can correlate the log line") + void traceIdIsCopiedFromTheMdc() { + MDC.put("traceId", TRACE_ID); + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + assertEquals(TRACE_ID, propertiesOf(problem).get("traceId")); + } + + @Test + @DisplayName("With an empty MDC the trace id is published as null instead of failing the response") + void traceIdIsNullWhenTheMdcIsEmpty() { + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + assertTrue(propertiesOf(problem).containsKey("traceId")); + assertNull(propertiesOf(problem).get("traceId")); + } + + @Test + @DisplayName("The trace id is published under the key the handler advertises as its contract") + void traceIdUsesTheAdvertisedPropertyName() { + MDC.put("traceId", TRACE_ID); + ProblemDetail problem = handler.handleUnexpected(new RuntimeException("boom"), request); + assertEquals(TRACE_ID, propertiesOf(problem).get(GlobalExceptionHandler.TRACE_ID_PROPERTY)); + } + + @Test + @DisplayName("An unrelated MDC entry is not mistaken for the trace id") + void unrelatedMdcEntriesAreIgnored() { + MDC.put("userId", "ana@example.com"); + ProblemDetail problem = handler.handleAccessDenied(new AccessDeniedException("denied"), request); + assertNull(propertiesOf(problem).get("traceId")); + assertFalse(propertiesOf(problem).containsValue("ana@example.com")); + } + + @Test + @DisplayName("Every handler publishes the trace id, not only the business one") + void everyHandlerPublishesTheTraceId() { + MDC.put("traceId", TRACE_ID); + assertEquals(TRACE_ID, propertiesOf(handler.handleValidation( + handlerMethodValidation(List.of()), request)).get("traceId")); + assertEquals(TRACE_ID, propertiesOf(handler.handleUnreadable( + mock(HttpMessageNotReadableException.class), request)).get("traceId")); + assertEquals(TRACE_ID, propertiesOf(handler.handleAccessDenied( + new AccessDeniedException("denied"), request)).get("traceId")); + } + } + + // ---------------------------------------------------------------------------------------- + // Common envelope + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Common envelope") + class CommonEnvelope { + + @Test + @DisplayName("Every response carries a timestamp so the client can tell two identical failures apart") + void publishesATimestamp() { + java.time.Instant antes = java.time.Instant.now().minusSeconds(1); + + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + + // assertNotNull pasaría con cualquier cadena, incluida una constante: hay que + // comprobar que es un instante y que corresponde a esta respuesta. + Object publicado = propertiesOf(problem).get("timestamp"); + assertNotNull(publicado); + java.time.Instant sello = java.time.Instant.parse(publicado.toString()); + assertTrue(sello.isAfter(antes) && sello.isBefore(java.time.Instant.now().plusSeconds(1)), + () -> "the timestamp does not belong to this response: " + sello); + } + + @Test + @DisplayName("The instance follows the request, so a different endpoint is reported under its own URI") + void instanceFollowsTheRequest() { + MockHttpServletRequest other = new MockHttpServletRequest("GET", "/api/v1/bookings/42"); + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND), other); + assertEquals(URI.create("/api/v1/bookings/42"), problem.getInstance()); + } + + @Test + @DisplayName("The documentation type is the lowercase name of the error code, so every code has its own page") + void typeIsDerivedFromTheErrorCodeName() { + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID), request); + assertEquals(URI.create(TYPE_PREFIX + "verification_token_invalid"), problem.getType()); + } + + /** + * Documents current behaviour, not desired behaviour: the advice builds the instance with + * {@code URI.create(request.getRequestURI())} without encoding, so a path the servlet + * container let through with a character illegal in a URI makes the advice itself blow up + * and the client receives a bare container error page instead of a ProblemDetail. + */ + @ParameterizedTest(name = "request URI [{0}] breaks the advice") + @ValueSource(strings = {"/api/v1/bookings/a b", "/api/v1/bookings/{id}", "/api/v1/bookings/a|b"}) + @DisplayName("A request URI with a character illegal in a URI makes the advice itself fail instead of answering a problem detail") + void requestUriWithIllegalCharacterBreaksTheAdvice(String rawUri) { + MockHttpServletRequest malformed = new MockHttpServletRequest("GET", rawUri); + assertThrows(IllegalArgumentException.class, + () -> handler.handleBusiness(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND), malformed)); + } + + @Test + @DisplayName("A request URI with accented characters is reported normally, because non-ASCII characters are tolerated in a URI path") + void requestUriWithAccentsIsReportedNormally() { + MockHttpServletRequest accented = new MockHttpServletRequest("GET", "/api/v1/servicios/masaje-relajación"); + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND), accented); + assertEquals(URI.create("/api/v1/servicios/masaje-relajación"), problem.getInstance()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/HttpStatusTranslationIT.java b/src/test/java/com/codefactory/bookingplatform/shared/error/HttpStatusTranslationIT.java new file mode 100644 index 0000000..5832cf3 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/HttpStatusTranslationIT.java @@ -0,0 +1,108 @@ +package com.codefactory.bookingplatform.shared.error; + +import com.codefactory.bookingplatform.support.JwtIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; + +/** + * Protocol-level contract of the API (Lineamientos: "respuestas uniformes de error con códigos + * HTTP correctos"). + * + *

{@code GlobalExceptionHandler} is annotated {@code @Order(HIGHEST_PRECEDENCE)} and declares + * {@code @ExceptionHandler(Exception.class)}. Spring resolves handler methods by walking the + * advices in order, so that catch-all sits in front of Spring MVC's own + * {@code DefaultHandlerExceptionResolver} / {@code ProblemDetailsExceptionHandler}, which are the + * things that turn {@code HttpRequestMethodNotSupportedException} into 405, + * {@code HttpMediaTypeNotSupportedException} into 415 and {@code NoResourceFoundException} into 404. + * + *

Every request below targets a {@code permitAll} path, so security is out of the picture and + * what we measure is purely the MVC translation. + */ +class HttpStatusTranslationIT extends JwtIntegrationTestBase { + + @Test + @DisplayName("DEFECT D9: an unsupported HTTP method returns 500 instead of 405") + void unsupportedMethodShouldBe405() throws Exception { + // /api/v1/registrations only maps POST + MockHttpServletResponse response = mockMvc.perform(get("/api/v1/registrations")) + .andReturn().getResponse(); + + assertEquals(500, response.getStatus(), """ + Current behaviour pinned. RFC 9110 and the API guideline require 405 Method Not Allowed \ + with an Allow header. If this now returns 405 the defect was fixed: flip the expectation."""); + assertEquals("INTERNAL_ERROR", errorCodeOf(response)); + assertEquals("", response.getHeader("Allow") == null ? "" : response.getHeader("Allow"), + "a 405 must carry Allow; today no Allow header is emitted at all"); + } + + @Test + @DisplayName("DEFECT D9: an unsupported media type returns 500 instead of 415") + void unsupportedMediaTypeShouldBe415() throws Exception { + MockHttpServletResponse response = mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.TEXT_PLAIN) + .content("fullName=Ana")) + .andReturn().getResponse(); + + assertEquals(500, response.getStatus(), """ + Current behaviour pinned. The guideline requires 415 Unsupported Media Type. \ + If this now returns 415 the defect was fixed: flip the expectation."""); + assertEquals("INTERNAL_ERROR", errorCodeOf(response)); + } + + @Test + @DisplayName("DEFECT D9: an unknown path under a public prefix returns 500 instead of 404") + void unknownPathShouldBe404() throws Exception { + MockHttpServletResponse response = mockMvc.perform(get("/api/v1/registrations/no-such-resource")) + .andReturn().getResponse(); + + assertEquals(500, response.getStatus(), """ + Current behaviour pinned. An unmapped path must answer 404 Not Found. \ + If this now returns 404 the defect was fixed: flip the expectation."""); + assertEquals("INTERNAL_ERROR", errorCodeOf(response)); + } + + @Test + @DisplayName("DEFECT D9: an unsupported method on an authenticated endpoint also returns 500") + void unsupportedMethodOnProtectedEndpointShouldBe405() throws Exception { + // /api/v1/auth/me maps GET only; a valid token gets us past security so MVC is what answers + String token = jwks().accessToken(java.util.UUID.randomUUID(), "ana.perez@example.com", "CLIENT"); + + MockHttpServletResponse response = mockMvc.perform(put("/api/v1/auth/me") + .header("Authorization", "Bearer " + token)) + .andReturn().getResponse(); + + assertEquals(500, response.getStatus(), + "Current behaviour pinned; 405 is the correct answer. Flip the expectation once fixed."); + } + + @Test + @DisplayName("Regression guard: malformed JSON still maps to 400 VALIDATION_ERROR") + void malformedJsonStillMapsTo400() throws Exception { + MockHttpServletResponse response = mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content("{ this is not json ")) + .andReturn().getResponse(); + + assertEquals(400, response.getStatus()); + assertEquals("VALIDATION_ERROR", errorCodeOf(response)); + } + + private static String errorCodeOf(MockHttpServletResponse response) throws Exception { + String body = response.getContentAsString(); + if (body == null || body.isBlank()) { + return ""; + } + try { + return com.jayway.jsonpath.JsonPath.read(body, "$.errorCode"); + } catch (RuntimeException ex) { + return ""; + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/observability/TraceIdFilterTest.java b/src/test/java/com/codefactory/bookingplatform/shared/observability/TraceIdFilterTest.java new file mode 100644 index 0000000..7c27ec8 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/observability/TraceIdFilterTest.java @@ -0,0 +1,232 @@ +package com.codefactory.bookingplatform.shared.observability; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Pure unit tests for the trace id propagation filter. The test lives in the + * filter package so that doFilterInternal can be driven directly, without a + * servlet container. + */ +class TraceIdFilterTest { + + private final TraceIdFilter filter = new TraceIdFilter(); + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private FilterChain chain; + + @BeforeEach + void setUp() { + MDC.clear(); + request = new MockHttpServletRequest("GET", "/api/v1/auth/me"); + response = new MockHttpServletResponse(); + chain = mock(FilterChain.class); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("An incoming X-Trace-Id is honoured and echoed back untouched") + void incomingTraceIdIsHonoured() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "trace-from-the-gateway"); + + filter.doFilterInternal(request, response, chain); + + assertEquals("trace-from-the-gateway", response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("The incoming trace id is the one visible in the MDC while the chain runs") + void incomingTraceIdIsVisibleInMdcDuringTheChain() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "trace-from-the-gateway"); + AtomicReference seenInsideChain = new AtomicReference<>(); + FilterChain capturing = (req, res) -> seenInsideChain.set(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + + filter.doFilterInternal(request, response, capturing); + + assertEquals("trace-from-the-gateway", seenInsideChain.get()); + } + + @ParameterizedTest(name = "a missing, empty or blank incoming header [{0}] is replaced by a generated id") + @NullSource + @ValueSource(strings = {"", " ", "\t"}) + void missingOrBlankHeaderIsReplaced(String incoming) throws Exception { + if (incoming != null) { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, incoming); + } + + filter.doFilterInternal(request, response, chain); + + String generated = response.getHeader(TraceIdFilter.TRACE_ID_HEADER); + assertDoesNotThrow(() -> UUID.fromString(generated)); + } + + @Test + @DisplayName("The response always carries the trace id header, even when none came in") + void responseAlwaysCarriesTheHeader() throws Exception { + filter.doFilterInternal(request, response, chain); + + assertNotNull(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("Two requests without an incoming header get two different generated ids") + void generatedIdsAreUnique() throws Exception { + filter.doFilterInternal(request, response, chain); + String first = response.getHeader(TraceIdFilter.TRACE_ID_HEADER); + + MockHttpServletResponse secondResponse = new MockHttpServletResponse(); + filter.doFilterInternal(new MockHttpServletRequest("GET", "/api/v1/auth/me"), secondResponse, chain); + String second = secondResponse.getHeader(TraceIdFilter.TRACE_ID_HEADER); + + org.junit.jupiter.api.Assertions.assertNotEquals(first, second); + } + + @Test + @DisplayName("The response header and the MDC value seen by the chain are the same id") + void headerAndMdcValueMatch() throws Exception { + AtomicReference seenInsideChain = new AtomicReference<>(); + FilterChain capturing = (req, res) -> seenInsideChain.set(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + + filter.doFilterInternal(request, response, capturing); + + assertEquals(seenInsideChain.get(), response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("The chain is invoked exactly once with the very same request and response") + void chainIsInvokedOnce() throws Exception { + filter.doFilterInternal(request, response, chain); + + verify(chain).doFilter(request, response); + } + + @Test + @DisplayName("The MDC is cleaned once the chain returns normally") + void mdcIsCleanedAfterASuccessfulChain() throws Exception { + filter.doFilterInternal(request, response, chain); + + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("CONTEXT LEAK GUARD: the MDC is cleaned even when the chain throws a ServletException") + void mdcIsCleanedWhenTheChainThrowsServletException() throws Exception { + doThrow(new ServletException("boom")).when(chain).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(ServletException.class, () -> filter.doFilterInternal(request, response, chain)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("CONTEXT LEAK GUARD: the MDC is cleaned even when the chain throws an IOException") + void mdcIsCleanedWhenTheChainThrowsIoException() throws Exception { + doThrow(new IOException("socket closed")).when(chain) + .doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(IOException.class, () -> filter.doFilterInternal(request, response, chain)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("CONTEXT LEAK GUARD: the MDC is cleaned even when the chain throws an unchecked exception") + void mdcIsCleanedWhenTheChainThrowsRuntimeException() throws Exception { + doThrow(new IllegalStateException("unexpected")).when(chain) + .doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(IllegalStateException.class, () -> filter.doFilterInternal(request, response, chain)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("A failing request still answers with the trace id header, so the caller can report it") + void headerIsSetBeforeTheChainRuns() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "trace-before-failure"); + doThrow(new IllegalStateException("unexpected")).when(chain) + .doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(IllegalStateException.class, () -> filter.doFilterInternal(request, response, chain)); + assertEquals("trace-before-failure", response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("A stale trace id left over from a previous request is overwritten, never reused") + void staleMdcValueIsOverwritten() throws Exception { + MDC.put(TraceIdFilter.TRACE_ID_MDC_KEY, "stale-value-from-a-previous-request"); + AtomicReference seenInsideChain = new AtomicReference<>(); + FilterChain capturing = (req, res) -> seenInsideChain.set(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "fresh-value"); + + filter.doFilterInternal(request, response, capturing); + + assertEquals("fresh-value", seenInsideChain.get()); + } + + @Test + @DisplayName("Going through the public doFilter entry point behaves like doFilterInternal") + void publicDoFilterEntryPointWorks() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "through-the-front-door"); + + filter.doFilter(request, response, chain); + + assertEquals("through-the-front-door", response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("Only one trace id header value is written, never a second appended one") + void headerIsSetNotAdded() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "single-value"); + + filter.doFilterInternal(request, response, chain); + + assertEquals(1, response.getHeaders(TraceIdFilter.TRACE_ID_HEADER).size()); + } + + @Test + @DisplayName("The published constants keep the contract the logging pattern relies on") + void constantsAreStable() { + assertEquals("X-Trace-Id", TraceIdFilter.TRACE_ID_HEADER); + assertEquals("traceId", TraceIdFilter.TRACE_ID_MDC_KEY); + } + + @Test + @DisplayName("A generated trace id is a random UUID, not a predictable counter") + void generatedTraceIdIsAUuid() throws Exception { + filter.doFilterInternal(request, response, chain); + + String generated = response.getHeader(TraceIdFilter.TRACE_ID_HEADER); + assertNotNull(generated); + assertTrue(generated.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/ActuatorHealthIT.java b/src/test/java/com/codefactory/bookingplatform/startup/ActuatorHealthIT.java new file mode 100644 index 0000000..c72c9ce --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/ActuatorHealthIT.java @@ -0,0 +1,127 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.support.PostgresIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroup; +import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.jdbc.health.DataSourceHealthIndicator; +import org.springframework.context.ApplicationContext; +import org.springframework.test.web.servlet.MockMvc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * What the actuator actually exposes, since Render polls it to decide whether a + * deploy is alive. Three things are pinned here: which endpoints answer without + * a token, how much they reveal, and what the readiness group is made of. + */ +class ActuatorHealthIT extends PostgresIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ApplicationContext context; + + @Nested + @DisplayName("Public health endpoints") + class PublicEndpoints { + + @Test + @DisplayName("/actuator/health answers 200 UP without any token") + void healthIsPublicAndUp() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + @DisplayName("/actuator/health hides its components, so the database host never leaks to an anonymous caller") + void healthShowsNoDetails() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.components").doesNotExist()) + .andExpect(content().json( + "{\"status\":\"UP\",\"groups\":[\"liveness\",\"readiness\"]}", true)); + } + + @ParameterizedTest(name = "{0} answers 200 without a token") + @ValueSource(strings = {"/actuator/health/readiness", "/actuator/health/liveness"}) + @DisplayName("Both availability probes are public and up, readiness being the one Render polls") + void probesArePublic(String path) throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + @DisplayName("/actuator/info is public as well") + void infoIsPublic() throws Exception { + mockMvc.perform(get("/actuator/info")).andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("Everything else on the actuator stays closed") + class ClosedEndpoints { + + @ParameterizedTest(name = "{0} is refused with 401 to an anonymous caller") + @ValueSource(strings = {"/actuator", "/actuator/beans", "/actuator/env", "/actuator/metrics", + "/actuator/configprops", "/actuator/loggers", "/actuator/threaddump"}) + @DisplayName("The unexposed endpoints are behind authentication, not merely unmapped") + void unexposedEndpointsRequireAuthentication(String path) throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + } + + @Nested + @DisplayName("Composition of the health groups") + class HealthGroups { + + @Test + @DisplayName("The liveness and readiness groups exist, which is what enables /actuator/health/") + void probeGroupsExist() { + HealthEndpointGroups groups = context.getBean(HealthEndpointGroups.class); + + assertTrue(groups.getNames().contains("liveness")); + assertTrue(groups.getNames().contains("readiness")); + } + + @Test + @DisplayName("RISK: the readiness group does not include db, so Render sees UP even with the database down") + void readinessIgnoresTheDatabase() { + HealthEndpointGroup readiness = context.getBean(HealthEndpointGroups.class).get("readiness"); + + assertNotNull(readiness); + assertFalse(readiness.isMember("db"), + "healthCheckPath is /actuator/health/readiness, which only reflects the application " + + "availability state, never the datasource"); + } + + @Test + @DisplayName("The database contributor is registered under db and is the JDBC one") + void databaseContributorIsRegistered() { + HealthIndicator indicator = (HealthIndicator) context.getBean("dbHealthContributor"); + + assertInstanceOf(DataSourceHealthIndicator.class, indicator); + assertEquals(Status.UP, indicator.health().getStatus()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/ApplicationContextStartupIT.java b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationContextStartupIT.java new file mode 100644 index 0000000..1722bb6 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationContextStartupIT.java @@ -0,0 +1,288 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.auth.domain.port.LoginAttemptRepository; +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.auth.infrastructure.persistence.LoginAttemptJpaRepository; +import com.codefactory.bookingplatform.auth.infrastructure.supabase.GoTrueClient; +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.identity.infrastructure.mapper.ClientMapper; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientJpaRepository; +import com.codefactory.bookingplatform.shared.config.AuthPolicyProperties; +import com.codefactory.bookingplatform.shared.config.SecurityProperties; +import com.codefactory.bookingplatform.shared.config.SupabaseJwtAuthConverter; +import com.codefactory.bookingplatform.shared.config.SupabaseProperties; +import com.codefactory.bookingplatform.shared.error.GlobalExceptionHandler; +import com.codefactory.bookingplatform.shared.observability.TraceIdFilter; +import com.codefactory.bookingplatform.support.PostgresIntegrationTestBase; +import io.swagger.v3.oas.models.OpenAPI; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpHeaders; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.client.RestClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.cors.CorsConfigurationSource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.time.Clock; +import java.time.Duration; +import java.time.ZoneOffset; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; + +/** + * The application context under the {@code test} profile: it must start, and it + * must start complete. A context that loads but is missing a bean, or that + * builds a policy from the wrong numbers, is a deployment that fails later and + * in production, which is exactly what this class exists to prevent. + */ +class ApplicationContextStartupIT extends PostgresIntegrationTestBase { + + @Autowired + private ApplicationContext context; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("The context starts") + class ContextStarts { + + @Test + @DisplayName("The application context is available and running") + void contextIsUp() { + assertNotNull(context); + assertNotNull(context.getId()); + } + + @Test + @DisplayName("The test profile is the active one, so the test datasource and schema apply") + void testProfileIsActive() { + assertEquals(Set.of("test"), Set.of(context.getEnvironment().getActiveProfiles())); + } + + @Test + @DisplayName("The schema strategy under test is create-drop, so every run starts from an empty database") + void schemaIsRecreatedForTheSuite() { + assertEquals("create-drop", context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto")); + } + } + + @Nested + @DisplayName("Domain policy beans carry the configured values") + class PolicyBeans { + + @Test + @DisplayName("LoginLockPolicy is built from app.auth-policy: 5 attempts, 15 minute window") + void loginLockPolicyMatchesConfiguration() { + LoginLockPolicy policy = context.getBean(LoginLockPolicy.class); + + assertEquals(5, policy.maxFailedAttempts()); + assertEquals(Duration.ofMinutes(15), policy.lockWindow()); + } + + @Test + @DisplayName("LoginLockPolicy agrees with the AuthPolicyProperties bean it was built from") + void loginLockPolicyAgreesWithItsProperties() { + AuthPolicyProperties properties = context.getBean(AuthPolicyProperties.class); + LoginLockPolicy policy = context.getBean(LoginLockPolicy.class); + + assertEquals(properties.maxFailedAttempts(), policy.maxFailedAttempts()); + assertEquals(Duration.ofMinutes(properties.lockWindowMinutes()), policy.lockWindow()); + } + + @Test + @DisplayName("The Clock bean runs on UTC, so audit timestamps do not drift with the host zone") + void clockIsUtc() { + assertEquals(ZoneOffset.UTC, context.getBean(Clock.class).getZone()); + } + + @Test + @DisplayName("There is exactly one Clock bean, so nothing can inject a different notion of now") + void clockIsUnique() { + assertEquals(1, context.getBeanNamesForType(Clock.class).length); + } + } + + @Nested + @DisplayName("Security beans") + class SecurityBeans { + + @Test + @DisplayName("The JwtDecoder is a Nimbus decoder, which is the JWKS-backed one") + void jwtDecoderIsNimbus() { + assertInstanceOf(NimbusJwtDecoder.class, context.getBean(JwtDecoder.class)); + } + + @Test + @DisplayName("There is exactly one SecurityFilterChain, so no second chain can shadow the rules") + void singleSecurityFilterChain() { + assertEquals(1, context.getBeanNamesForType(SecurityFilterChain.class).length); + assertNotNull(context.getBean(SecurityFilterChain.class)); + } + + @Test + @DisplayName("The security properties point at the Supabase auth endpoints of the configured project") + void securityPropertiesArePresent() { + SecurityProperties properties = context.getBean(SecurityProperties.class); + + assertTrue(properties.jwtIssuer().endsWith("/auth/v1")); + assertTrue(properties.jwksUri().endsWith("/.well-known/jwks.json")); + } + + @Test + @DisplayName("The Supabase JWT converter bean is registered, which is what maps app_metadata.role") + void jwtConverterIsRegistered() { + assertNotNull(context.getBean(SupabaseJwtAuthConverter.class)); + } + + @Test + @DisplayName("RISK: the only CorsConfigurationSource is Spring MVC's introspector, no application CORS policy exists") + void thereIsNoApplicationDefinedCorsPolicy() { + String[] names = context.getBeanNamesForType(CorsConfigurationSource.class); + + assertEquals(1, names.length); + assertEquals("mvcHandlerMappingIntrospector", names[0], + "http.cors(withDefaults()) falls back to the MVC introspector; " + + "render.yaml declares ALLOWED_ORIGINS but nothing turns it into a policy"); + } + + @Test + @DisplayName("RISK: a cross origin preflight gets no Access-Control-Allow-Origin, so a browser frontend is blocked") + void preflightGetsNoCorsHeaders() throws Exception { + mockMvc.perform(options("/api/v1/auth/login") + .header(HttpHeaders.ORIGIN, "https://frontend.example.com") + .header("Access-Control-Request-Method", "POST")) + .andExpect(header().doesNotExist("Access-Control-Allow-Origin")); + } + } + + @Nested + @DisplayName("Persistence beans") + class PersistenceBeans { + + @Test + @DisplayName("Both Spring Data repositories are created") + void jpaRepositoriesExist() { + assertNotNull(context.getBean(ClientJpaRepository.class)); + assertNotNull(context.getBean(LoginAttemptJpaRepository.class)); + } + + @Test + @DisplayName("Both domain ports are satisfied by their persistence adapters") + void domainPortsAreAdapted() { + assertNotNull(context.getBean(ClientRepository.class)); + assertNotNull(context.getBean(LoginAttemptRepository.class)); + } + + @Test + @DisplayName("The MapStruct mapper is a Spring bean, which is what the adapters inject") + void mapperIsABean() { + assertNotNull(context.getBean(ClientMapper.class)); + } + + @Test + @DisplayName("The DataSource hands out a working connection to the real database") + void dataSourceConnects() throws Exception { + try (Connection connection = context.getBean(DataSource.class).getConnection()) { + assertTrue(connection.isValid(5)); + assertEquals("PostgreSQL", connection.getMetaData().getDatabaseProductName()); + } + } + + @Test + @DisplayName("Hibernate created the two Sprint 1 tables") + void schemaWasCreated() throws Exception { + try (Connection connection = context.getBean(DataSource.class).getConnection(); + var statement = connection.createStatement(); + var rows = statement.executeQuery( + "select count(*) from information_schema.tables " + + "where table_schema = 'public' and table_name in ('clients','login_attempts')")) { + assertTrue(rows.next()); + assertEquals(2, rows.getInt(1)); + } + } + } + + @Nested + @DisplayName("Application and adapter beans") + class ApplicationBeans { + + @Test + @DisplayName("Every authentication use case is wired") + void authUseCasesExist() { + assertNotNull(context.getBean(LoginUseCase.class)); + assertNotNull(context.getBean(LogoutUseCase.class)); + assertNotNull(context.getBean(PasswordRecoveryUseCase.class)); + assertNotNull(context.getBean(PasswordResetUseCase.class)); + assertNotNull(context.getBean(UserProvisioning.class)); + } + + @Test + @DisplayName("Every registration use case is wired") + void identityUseCasesExist() { + assertNotNull(context.getBean(RegisterClientUseCase.class)); + assertNotNull(context.getBean(ConfirmEmailUseCase.class)); + assertNotNull(context.getBean(ResendVerificationUseCase.class)); + } + + @Test + @DisplayName("The identity provider port is satisfied by the Supabase GoTrue adapter") + void identityProviderIsGoTrue() { + assertInstanceOf(GoTrueClient.class, context.getBean(IdentityProviderPort.class)); + } + + @Test + @DisplayName("The RestClient builder used to reach Supabase is available from the container") + void restClientBuilderIsAvailable() { + assertNotNull(context.getBean(RestClient.Builder.class)); + } + + @Test + @DisplayName("The Supabase properties are bound and carry a non blank URL") + void supabasePropertiesAreBound() { + SupabaseProperties properties = context.getBean(SupabaseProperties.class); + + assertNotNull(properties); + assertFalse(properties.url().isBlank()); + } + + @Test + @DisplayName("The cross cutting beans are registered: trace id filter and the error handler") + void crossCuttingBeansExist() { + assertNotNull(context.getBean(TraceIdFilter.class)); + assertNotNull(context.getBean(GlobalExceptionHandler.class)); + } + + @Test + @DisplayName("The OpenAPI contract bean is built, so Swagger UI has something to render") + void openApiBeanExists() { + OpenAPI openApi = context.getBean(OpenAPI.class); + + assertEquals("Booking Platform API", openApi.getInfo().getTitle()); + assertEquals("v1", openApi.getInfo().getVersion()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/ApplicationStartsIT.java b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationStartsIT.java new file mode 100644 index 0000000..803cb26 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationStartsIT.java @@ -0,0 +1,110 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.support.PostgresIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The application running for real: an embedded Tomcat on a random port, a + * PostgreSQL in Docker behind it and plain HTTP requests from outside the + * container. Everything else in this package inspects the context; this class + * checks that the process actually serves traffic, which is the question the + * demo answers. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ApplicationStartsIT extends PostgresIntegrationTestBase { + + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + @LocalServerPort + private int port; + + private HttpResponse get(String path) throws Exception { + return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .timeout(Duration.ofSeconds(20)) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body) throws Exception { + return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), HttpResponse.BodyHandlers.ofString()); + } + + @Test + @DisplayName("The embedded server is listening on a real port") + void serverIsListening() { + assertTrue(port > 0); + } + + @Test + @DisplayName("GET /actuator/health answers 200 UP over real HTTP") + void healthAnswersOverHttp() throws Exception { + HttpResponse response = get("/actuator/health"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"status\":\"UP\""), response.body()); + } + + @Test + @DisplayName("GET /actuator/health/readiness, the path render.yaml polls, answers 200 over real HTTP") + void readinessAnswersOverHttp() throws Exception { + HttpResponse response = get("/actuator/health/readiness"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"status\":\"UP\""), response.body()); + } + + @Test + @DisplayName("The OpenAPI contract is served publicly, so Swagger UI works without a token") + void openApiContractIsPublic() throws Exception { + HttpResponse response = get("/v3/api-docs"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("Booking Platform API"), response.body()); + } + + @Test + @DisplayName("A public endpoint is reachable without a token: a malformed registration gets 400, not 401") + void publicEndpointAnswersWithoutToken() throws Exception { + HttpResponse response = postJson("/api/v1/registrations", "{}"); + + assertEquals(400, response.statusCode(), response.body()); + assertTrue(response.body().contains("VALIDATION_ERROR"), response.body()); + } + + @Test + @DisplayName("A protected endpoint answers 401 AUTH_REQUIRED when no token is sent") + void protectedEndpointRequiresAToken() throws Exception { + HttpResponse response = get("/api/v1/auth/me"); + + assertEquals(401, response.statusCode(), response.body()); + assertTrue(response.body().contains("AUTH_REQUIRED"), response.body()); + assertTrue(response.headers().firstValue("Content-Type").orElse("") + .startsWith("application/problem+json"), response.headers().toString()); + } + + @Test + @DisplayName("Every error response carries the X-Trace-Id header used to correlate the logs") + void errorsCarryATraceId() throws Exception { + HttpResponse response = get("/api/v1/auth/me"); + + assertTrue(response.headers().firstValue("X-Trace-Id").isPresent(), response.headers().toString()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java b/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java new file mode 100644 index 0000000..a757120 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java @@ -0,0 +1,138 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.shared.config.SupabaseProperties; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.Statement; +import java.time.Duration; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The profile that actually ships. Render runs the image with + * {@code SPRING_PROFILES_ACTIVE=cloud}, and that profile pins + * {@code ddl-auto=validate}: Hibernate refuses to start if the database does + * not already match the entity mapping. + * + *

So the container is created from {@code docs/database/schema.sql}, the + * physical model committed for Sprint 1, and the context is started on top of + * it. A green run here is the evidence that the documented DDL and the code + * agree; a red one is a deployment that dies on boot with no way to recover + * from the outside. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("cloud") +class CloudProfileContextIT { + + private static final Path SCHEMA = Path.of("docs", "database", "schema.sql"); + + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + static { + POSTGRES.start(); + applyCommittedSchema(); + } + + private static void applyCommittedSchema() { + try (Connection connection = java.sql.DriverManager.getConnection( + POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + Statement statement = connection.createStatement()) { + statement.execute(Files.readString(SCHEMA)); + } catch (Exception ex) { + throw new IllegalStateException("Could not apply " + SCHEMA.toAbsolutePath(), ex); + } + } + + @DynamicPropertySource + static void registerDatasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + } + + @Autowired + private ApplicationContext context; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("The cloud profile starts against the committed schema.sql, which is what Render does on boot") + void cloudProfileStarts() { + assertNotNull(context); + assertEquals(Set.of("cloud"), Set.of(context.getEnvironment().getActiveProfiles())); + } + + @Test + @DisplayName("ddl-auto is validate, so Hibernate verified every mapping against the committed DDL") + void schemaIsValidatedNotCreated() { + assertEquals("validate", context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto")); + } + + @Test + @DisplayName("The Hikari pool is capped at 5 connections, the limit the Supabase free pooler imposes") + void hikariPoolIsCapped() throws Exception { + HikariDataSource dataSource = context.getBean(DataSource.class).unwrap(HikariDataSource.class); + + assertEquals(5, dataSource.getMaximumPoolSize()); + assertEquals(1, dataSource.getMinimumIdle()); + } + + @Test + @DisplayName("The critical beans exist under the cloud profile too, not only under test") + void criticalBeansExistInCloud() { + LoginLockPolicy policy = context.getBean(LoginLockPolicy.class); + + assertEquals(5, policy.maxFailedAttempts()); + assertEquals(Duration.ofMinutes(15), policy.lockWindow()); + assertNotNull(context.getBean(JwtDecoder.class)); + assertNotNull(context.getBean(SecurityFilterChain.class)); + assertNotNull(context.getBean(java.time.Clock.class)); + } + + @Test + @DisplayName("RISK: with no SUPABASE_SECRET_KEY the cloud profile still starts, holding an empty credential") + void cloudProfileStartsWithoutACredential() { + SupabaseProperties properties = context.getBean(SupabaseProperties.class); + + assertTrue(properties.secretKey().isBlank(), + "no variable was provided, so the deployment would be running with no Supabase credential"); + assertEquals("https://placeholder.supabase.co", properties.url()); + } + + @Test + @DisplayName("The health endpoint answers on the cloud profile, which is what Render polls") + void healthAnswersInCloud() throws Exception { + mockMvc.perform(get("/actuator/health")).andExpect(status().isOk()); + } + + @Test + @DisplayName("The readiness probe Render is configured to use answers on the cloud profile") + void readinessAnswersInCloud() throws Exception { + mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/DatabaseDownHealthTest.java b/src/test/java/com/codefactory/bookingplatform/startup/DatabaseDownHealthTest.java new file mode 100644 index 0000000..3d2b1e1 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/DatabaseDownHealthTest.java @@ -0,0 +1,72 @@ +package com.codefactory.bookingplatform.startup; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.jdbc.health.DataSourceHealthIndicator; + +import javax.sql.DataSource; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * What {@code /actuator/health} reports when the database stops answering. + * + *

The indicator wired by the application is exercised against a datasource + * that refuses connections, which is what a Supabase pooler at its 15 client + * limit looks like from inside the application. The aggregate status turns + * DOWN, so {@code /actuator/health} answers 503 — while + * {@code /actuator/health/readiness}, the path configured in + * {@code render.yaml}, stays UP because the readiness group has no database + * member (pinned in {@code ActuatorHealthIT}). + */ +class DatabaseDownHealthTest { + + private static DataSource refusingDataSource(String message) throws SQLException { + DataSource dataSource = mock(DataSource.class); + when(dataSource.getConnection()).thenThrow(new SQLException(message)); + return dataSource; + } + + @Test + @DisplayName("An unreachable database turns the db indicator DOWN") + void unreachableDatabaseIsDown() throws SQLException { + DataSourceHealthIndicator indicator = + new DataSourceHealthIndicator(refusingDataSource("Connection refused")); + + assertEquals(Status.DOWN, indicator.health().getStatus()); + } + + @Test + @DisplayName("RISK: the details name the generic JDBC failure, the driver's root cause is dropped") + void rootCauseIsNotReported() throws SQLException { + DataSourceHealthIndicator indicator = new DataSourceHealthIndicator( + refusingDataSource("FATAL: (EMAXCONNSESSION) max clients reached")); + + Health health = indicator.health(); + String error = String.valueOf(health.getDetails().get("error")); + + assertNotNull(health.getDetails().get("error")); + assertEquals("org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection", + error); + assertFalse(error.contains("EMAXCONNSESSION"), + "diagnosing a pooler exhaustion needs the application log, the endpoint does not carry it"); + } + + @Test + @DisplayName("RISK: the details exist but management.endpoint.health.show-details is never, so the caller only sees DOWN") + void detailsAreNotExposedToTheCaller() throws SQLException { + DataSourceHealthIndicator indicator = + new DataSourceHealthIndicator(refusingDataSource("Connection refused")); + + assertFalse(indicator.health().getDetails().isEmpty(), + "the indicator does produce details; it is the endpoint configuration that hides them"); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/EnvironmentConfigurationResilienceTest.java b/src/test/java/com/codefactory/bookingplatform/startup/EnvironmentConfigurationResilienceTest.java new file mode 100644 index 0000000..fab2462 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/EnvironmentConfigurationResilienceTest.java @@ -0,0 +1,443 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.shared.config.AuthPolicyProperties; +import com.codefactory.bookingplatform.shared.config.SecurityProperties; +import com.codefactory.bookingplatform.shared.config.SupabaseProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What the application does when a deployment variable is missing or empty. + * + *

Every case runs through {@link ApplicationContextRunner} with the real + * {@code application.yml} loaded by {@link ConfigDataApplicationContextInitializer}, + * so the placeholders and their defaults are the production ones. The operating + * system environment of the machine running the suite is swapped for a + * controlled {@link SystemEnvironmentPropertySource}: that makes a variable + * genuinely absent (or genuinely present, with relaxed binding intact) no + * matter where the tests run. + * + *

These tests pin the behaviour as it is today, defects included. Where the + * recorded behaviour is a deployment risk it is spelled out in the test name, + * so a change in {@code src/main} that fixes it fails here loudly and on + * purpose. + */ +class EnvironmentConfigurationResilienceTest { + + /** A runner whose process environment contains exactly {@code variables}. */ + private static ApplicationContextRunner runnerWithEnvironment(Map variables) { + return new ApplicationContextRunner() + .withInitializer((ConfigurableApplicationContext context) -> + context.getEnvironment().getPropertySources().replace( + StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, + new SystemEnvironmentPropertySource( + StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, + variables))) + .withInitializer(new ConfigDataApplicationContextInitializer()) + .withUserConfiguration(BoundProperties.class); + } + + /** A runner that sees no deployment variable at all: every default applies. */ + private static ApplicationContextRunner runnerWithoutVariables() { + return runnerWithEnvironment(Map.of()); + } + + private static ApplicationContextRunner runnerWith(String name, Object value) { + Map variables = new LinkedHashMap<>(); + variables.put(name, value); + return runnerWithEnvironment(variables); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties({SupabaseProperties.class, SecurityProperties.class, AuthPolicyProperties.class}) + static class BoundProperties { + } + + @Nested + @DisplayName("The harness itself: absence and presence are simulated faithfully") + class RunnerContract { + + @Test + @DisplayName("The process environment is replaced, so the host machine cannot leak into the scenarios") + void processEnvironmentIsReplaced() { + runnerWith("SUPABASE_URL", "https://sentinel.example.com").run(context -> { + StandardEnvironment environment = (StandardEnvironment) context.getEnvironment(); + SystemEnvironmentPropertySource source = (SystemEnvironmentPropertySource) environment + .getPropertySources().get(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME); + assertNotNull(source); + assertEquals("https://sentinel.example.com", source.getProperty("SUPABASE_URL")); + assertNull(source.getProperty("PATH"), "the real OS environment must be gone"); + }); + } + + @Test + @DisplayName("application.yml is still loaded, so the production defaults are what is under test") + void applicationYamlIsLoaded() { + runnerWithoutVariables().run(context -> assertEquals("bookingplatform", + context.getEnvironment().getProperty("spring.application.name"))); + } + } + + @Nested + @DisplayName("SUPABASE_SECRET_KEY") + class SupabaseSecretKey { + + @Test + @DisplayName("RISK: when it is missing the context still starts and the secret key binds to an empty string") + void missingSecretKeyStartsWithAnEmptyCredential() { + runnerWithoutVariables().run(context -> { + assertNull(context.getStartupFailure(), "the application starts with no credential at all"); + assertEquals("", context.getBean(SupabaseProperties.class).secretKey()); + }); + } + + @Test + @DisplayName("RISK: an empty value is indistinguishable from a missing one, also accepted") + void emptySecretKeyIsAccepted() { + runnerWith("SUPABASE_SECRET_KEY", "").run(context -> { + assertNull(context.getStartupFailure()); + assertEquals("", context.getBean(SupabaseProperties.class).secretKey()); + }); + } + + @ParameterizedTest(name = "a blank-but-not-empty value of [{0}] is accepted too") + @ValueSource(strings = {" ", " ", "\t"}) + @DisplayName("RISK: whitespace passes as a credential because nothing validates the field") + void blankSecretKeyIsAccepted(String blank) { + runnerWith("SUPABASE_SECRET_KEY", blank).run(context -> { + assertNull(context.getStartupFailure()); + assertTrue(context.getBean(SupabaseProperties.class).secretKey().isBlank()); + }); + } + + @Test + @DisplayName("A real value is bound unchanged") + void realSecretKeyIsBound() { + runnerWith("SUPABASE_SECRET_KEY", "sb_secret_abc123").run(context -> + assertEquals("sb_secret_abc123", context.getBean(SupabaseProperties.class).secretKey())); + } + } + + @Nested + @DisplayName("SUPABASE_URL") + class SupabaseUrl { + + @Test + @DisplayName("RISK: when it is missing the app starts pointed at a placeholder host that does not exist") + void missingUrlFallsBackToPlaceholder() { + runnerWithoutVariables().run(context -> { + assertNull(context.getStartupFailure()); + assertEquals("https://placeholder.supabase.co", + context.getBean(SupabaseProperties.class).url()); + }); + } + + @Test + @DisplayName("RISK: the placeholder also propagates to the JWT issuer and the JWKS URI") + void missingUrlPoisonsTheSecurityProperties() { + runnerWithoutVariables().run(context -> { + SecurityProperties security = context.getBean(SecurityProperties.class); + assertEquals("https://placeholder.supabase.co/auth/v1", security.jwtIssuer()); + assertEquals("https://placeholder.supabase.co/auth/v1/.well-known/jwks.json", security.jwksUri()); + }); + } + + @Test + @DisplayName("One variable drives three values: setting it fixes issuer and JWKS at once") + void oneVariableDrivesTheWholeAuthConfiguration() { + runnerWith("SUPABASE_URL", "https://abc.supabase.co").run(context -> { + assertEquals("https://abc.supabase.co", context.getBean(SupabaseProperties.class).url()); + SecurityProperties security = context.getBean(SecurityProperties.class); + assertEquals("https://abc.supabase.co/auth/v1", security.jwtIssuer()); + assertEquals("https://abc.supabase.co/auth/v1/.well-known/jwks.json", security.jwksUri()); + }); + } + + @Test + @DisplayName("RISK: a trailing slash is not normalised and produces a double slash in the issuer") + void trailingSlashIsNotNormalised() { + runnerWith("SUPABASE_URL", "https://abc.supabase.co/").run(context -> + assertEquals("https://abc.supabase.co//auth/v1", + context.getBean(SecurityProperties.class).jwtIssuer())); + } + } + + @Nested + @DisplayName("JWT_ISSUER and JWKS_URI") + class JwtIssuerAndJwksUri { + + @Test + @DisplayName("RISK: JWT_ISSUER and JWKS_URI are NOT read; the yaml only honours SUPABASE_URL") + void dedicatedVariablesAreIgnored() { + Map variables = new LinkedHashMap<>(); + variables.put("JWT_ISSUER", "https://tenant.example.com/auth/v1"); + variables.put("JWKS_URI", "https://tenant.example.com/auth/v1/.well-known/jwks.json"); + + runnerWithEnvironment(variables).run(context -> { + SecurityProperties security = context.getBean(SecurityProperties.class); + assertEquals("https://placeholder.supabase.co/auth/v1", security.jwtIssuer()); + assertEquals("https://placeholder.supabase.co/auth/v1/.well-known/jwks.json", security.jwksUri()); + }); + } + + @Test + @DisplayName("The relaxed-binding names APP_SECURITY_JWT_ISSUER and APP_SECURITY_JWKS_URI do override") + void relaxedBindingNamesDoOverride() { + Map variables = new LinkedHashMap<>(); + variables.put("APP_SECURITY_JWT_ISSUER", "https://tenant.example.com/auth/v1"); + variables.put("APP_SECURITY_JWKS_URI", "https://tenant.example.com/jwks.json"); + + runnerWithEnvironment(variables).run(context -> { + SecurityProperties security = context.getBean(SecurityProperties.class); + assertEquals("https://tenant.example.com/auth/v1", security.jwtIssuer()); + assertEquals("https://tenant.example.com/jwks.json", security.jwksUri()); + }); + } + } + + @Nested + @DisplayName("DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD") + class DatabaseVariables { + + @Test + @DisplayName("RISK: a missing DATABASE_URL silently points the app at a local database") + void missingDatabaseUrlFallsBackToLocalhost() { + runnerWithoutVariables().run(context -> assertEquals( + "jdbc:postgresql://localhost:5432/bookingplatform", + context.getEnvironment().getProperty("spring.datasource.url"))); + } + + @Test + @DisplayName("RISK: missing credentials fall back to postgres/postgres instead of failing") + void missingCredentialsFallBackToPostgres() { + runnerWithoutVariables().run(context -> { + assertEquals("postgres", context.getEnvironment().getProperty("spring.datasource.username")); + assertEquals("postgres", context.getEnvironment().getProperty("spring.datasource.password")); + }); + } + + @Test + @DisplayName("The three variables are honoured when present") + void databaseVariablesAreHonoured() { + Map variables = new LinkedHashMap<>(); + variables.put("DATABASE_URL", "jdbc:postgresql://pooler.supabase.com:5432/postgres?sslmode=require"); + variables.put("DATABASE_USER", "postgres.abc"); + variables.put("DATABASE_PASSWORD", "s3cr3t"); + + runnerWithEnvironment(variables).run(context -> { + assertEquals("jdbc:postgresql://pooler.supabase.com:5432/postgres?sslmode=require", + context.getEnvironment().getProperty("spring.datasource.url")); + assertEquals("postgres.abc", context.getEnvironment().getProperty("spring.datasource.username")); + assertEquals("s3cr3t", context.getEnvironment().getProperty("spring.datasource.password")); + }); + } + + @Test + @DisplayName("RISK: an empty DATABASE_URL is taken literally, it does not fall back to the default") + void emptyDatabaseUrlIsTakenLiterally() { + runnerWith("DATABASE_URL", "").run(context -> + assertEquals("", context.getEnvironment().getProperty("spring.datasource.url"))); + } + + @Test + @DisplayName("A database that refuses connections fails at startup, so a broken deploy never serves traffic") + void unreachableDatabaseFailsAtStartup() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class)) + .withPropertyValues( + "spring.datasource.url=jdbc:postgresql://127.0.0.1:1/absent", + "spring.datasource.username=postgres", + "spring.datasource.password=postgres", + "spring.datasource.hikari.initialization-fail-timeout=1", + "spring.datasource.hikari.connection-timeout=250", + "spring.jpa.hibernate.ddl-auto=validate") + .run(context -> assertNotNull(context.getStartupFailure(), + "the database is the one dependency that is checked eagerly")); + } + } + + @Nested + @DisplayName("PORT") + class Port { + + @Test + @DisplayName("A missing PORT falls back to 8080, the port the Dockerfile exposes") + void missingPortFallsBackTo8080() { + runnerWithoutVariables().run(context -> + assertEquals("8080", context.getEnvironment().getProperty("server.port"))); + } + + @Test + @DisplayName("The PORT injected by the platform is honoured, which is what Render needs") + void injectedPortIsHonoured() { + runnerWith("PORT", "10000").run(context -> + assertEquals("10000", context.getEnvironment().getProperty("server.port"))); + } + } + + @Nested + @DisplayName("JPA_DDL_AUTO and SPRING_PROFILES_ACTIVE") + class SchemaManagement { + + @Test + @DisplayName("Without a profile the schema strategy is update, which lets Hibernate create tables") + void defaultStrategyIsUpdate() { + runnerWithoutVariables().run(context -> + assertEquals("update", context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto"))); + } + + @Test + @DisplayName("JPA_DDL_AUTO is honoured when no profile pins the strategy") + void variableIsHonouredByDefault() { + runnerWith("JPA_DDL_AUTO", "none").run(context -> + assertEquals("none", context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto"))); + } + + @Test + @DisplayName("RISK: under the cloud profile JPA_DDL_AUTO is ignored, the strategy is hardcoded to validate") + void cloudProfileIgnoresTheVariable() { + runnerWith("JPA_DDL_AUTO", "update") + .withPropertyValues("spring.profiles.active=cloud") + .run(context -> assertEquals("validate", + context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto"))); + } + + @Test + @DisplayName("The cloud profile shrinks the Hikari pool to 5, as the Supabase free pooler demands") + void cloudProfileShrinksThePool() { + runnerWithoutVariables() + .withPropertyValues("spring.profiles.active=cloud") + .run(context -> { + assertEquals("5", context.getEnvironment() + .getProperty("spring.datasource.hikari.maximum-pool-size")); + assertEquals("1", context.getEnvironment() + .getProperty("spring.datasource.hikari.minimum-idle")); + }); + } + + @Test + @DisplayName("SPRING_PROFILES_ACTIVE=cloud, the value render.yaml sets, activates the cloud profile") + void springProfilesActiveActivatesCloud() { + runnerWith("SPRING_PROFILES_ACTIVE", "cloud").run(context -> { + assertEquals(1, context.getEnvironment().getActiveProfiles().length); + assertEquals("cloud", context.getEnvironment().getActiveProfiles()[0]); + assertEquals("validate", context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto")); + }); + } + + @Test + @DisplayName("RISK: without SPRING_PROFILES_ACTIVE no profile is active and ddl-auto stays at update") + void withoutTheProfileTheSchemaIsMutable() { + runnerWithoutVariables().run(context -> { + assertEquals(0, context.getEnvironment().getActiveProfiles().length); + assertEquals("update", context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto")); + }); + } + } + + @Nested + @DisplayName("ALLOWED_ORIGINS and APP_BASE_URL (declared in render.yaml)") + class UnusedRenderVariables { + + @Test + @DisplayName("RISK: ALLOWED_ORIGINS is declared in render.yaml but no property in the app consumes it") + void allowedOriginsIsNotWiredToAnything() { + runnerWith("ALLOWED_ORIGINS", "https://frontend.example.com").run(context -> { + assertNull(context.getEnvironment().getProperty("spring.web.cors.allowed-origins")); + assertNull(context.getEnvironment().getProperty("app.cors.allowed-origins")); + assertNull(context.getEnvironment().getProperty("app.security.allowed-origins")); + }); + } + + @Test + @DisplayName("RISK: APP_BASE_URL becomes app.base-url by relaxed binding, but no @ConfigurationProperties reads it") + void appBaseUrlIsVisibleButUnconsumed() { + runnerWith("APP_BASE_URL", "https://bookingplatform.example.com").run(context -> { + assertEquals("https://bookingplatform.example.com", + context.getEnvironment().getProperty("app.base-url")); + + assertTrue(recordComponentsOf(SupabaseProperties.class, SecurityProperties.class, + AuthPolicyProperties.class).stream().noneMatch("baseUrl"::equals), + "no configuration properties type binds app.base-url, so the value is dead configuration"); + }); + } + + private java.util.List recordComponentsOf(Class... types) { + return java.util.Arrays.stream(types) + .flatMap(type -> java.util.Arrays.stream(type.getRecordComponents())) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + } + } + + @Nested + @DisplayName("app.auth-policy") + class AuthPolicy { + + @Test + @DisplayName("The lock policy defaults are fixed in the yaml: 5 attempts in a 15 minute window") + void policyDefaults() { + runnerWithoutVariables().run(context -> { + AuthPolicyProperties policy = context.getBean(AuthPolicyProperties.class); + assertNotNull(policy); + assertEquals(5, policy.maxFailedAttempts()); + assertEquals(15, policy.lockWindowMinutes()); + }); + } + + @Test + @DisplayName("The policy is overridable per environment through relaxed binding") + void policyIsOverridable() { + Map variables = new LinkedHashMap<>(); + variables.put("APP_AUTH_POLICY_MAX_FAILED_ATTEMPTS", "3"); + variables.put("APP_AUTH_POLICY_LOCK_WINDOW_MINUTES", "30"); + + runnerWithEnvironment(variables).run(context -> { + AuthPolicyProperties policy = context.getBean(AuthPolicyProperties.class); + assertEquals(3, policy.maxFailedAttempts()); + assertEquals(30, policy.lockWindowMinutes()); + }); + } + } + + @Nested + @DisplayName("Actuator exposure") + class ActuatorExposure { + + @Test + @DisplayName("Only health and info are exposed over HTTP") + void onlyHealthAndInfoAreExposed() { + runnerWithoutVariables().run(context -> assertEquals("health,info", + context.getEnvironment().getProperty("management.endpoints.web.exposure.include"))); + } + + @Test + @DisplayName("Health probes are enabled, which is what creates /actuator/health/readiness for Render") + void probesAreEnabled() { + runnerWithoutVariables().run(context -> assertEquals("true", + context.getEnvironment().getProperty("management.endpoint.health.probes.enabled"))); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/MissingSupabaseCredentialTest.java b/src/test/java/com/codefactory/bookingplatform/startup/MissingSupabaseCredentialTest.java new file mode 100644 index 0000000..54852b8 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/MissingSupabaseCredentialTest.java @@ -0,0 +1,146 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.auth.domain.model.AppRole; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.infrastructure.supabase.GoTrueClient; +import com.codefactory.bookingplatform.shared.config.SecurityConfig; +import com.codefactory.bookingplatform.shared.config.SecurityProperties; +import com.codefactory.bookingplatform.shared.config.SupabaseProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +/** + * The failure mode of starting without {@code SUPABASE_SECRET_KEY}, pinned end + * to end: the application starts, the adapter is built, the request leaves with + * an empty credential and only then does the call fail, in front of whoever is + * watching. + * + *

The last test of the first group is the one that matters for an operator: + * the resulting error reaching the caller is {@code INVALID_CREDENTIALS}, which + * reads as "wrong password" and points the diagnosis away from the real cause. + */ +class MissingSupabaseCredentialTest { + + private static final String BASE = "https://demo.supabase.co"; + private static final String NO_KEY = ""; + + private MockRestServiceServer server; + private GoTrueClient clientWithoutCredential; + + @BeforeEach + void setUp() { + RestClient.Builder realBuilder = RestClient.builder().baseUrl(BASE); + server = MockRestServiceServer.bindTo(realBuilder).build(); + RestClient.Builder builder = mock(RestClient.Builder.class); + when(builder.baseUrl(anyString())).thenReturn(builder); + when(builder.build()).thenReturn(realBuilder.build()); + clientWithoutCredential = new GoTrueClient(new SupabaseProperties(BASE, NO_KEY), builder); + } + + @Nested + @DisplayName("The identity adapter accepts an empty secret key") + class AdapterIsBuiltAnyway { + + @Test + @DisplayName("RISK: building the adapter with an empty secret key does not fail, so nothing warns at startup") + void adapterIsBuiltWithoutCredential() { + assertNotNull(clientWithoutCredential); + } + + @Test + @DisplayName("RISK: the login call leaves with an empty apikey header instead of being refused locally") + void loginRequestCarriesAnEmptyApiKey() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", "")) + .andRespond(withStatus(org.springframework.http.HttpStatus.UNAUTHORIZED) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"message\":\"Invalid API key\"}")); + + assertThrows(UpstreamAuthException.class, + () -> clientWithoutCredential.requestPasswordToken("ana.perez@example.com", "Str0ng!Pass")); + server.verify(); + } + + @Test + @DisplayName("RISK: Supabase answering 401 to an unauthenticated login is reported as INVALID_CREDENTIALS") + void missingCredentialLooksLikeAWrongPassword() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withStatus(org.springframework.http.HttpStatus.UNAUTHORIZED) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"message\":\"Invalid API key\"}")); + + UpstreamAuthException failure = assertThrows(UpstreamAuthException.class, + () -> clientWithoutCredential.requestPasswordToken("ana.perez@example.com", "Str0ng!Pass")); + + assertEquals(UpstreamAuthError.INVALID_CREDENTIALS, failure.error(), + "a missing deployment credential is reported to the user as a wrong password"); + } + + @Test + @DisplayName("Registration fails with UNAVAILABLE instead, so the same root cause shows two different faces") + void registrationReportsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", "")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer ")) + .andRespond(withStatus(org.springframework.http.HttpStatus.UNAUTHORIZED) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"message\":\"Invalid API key\"}")); + + UpstreamAuthException failure = assertThrows(UpstreamAuthException.class, + () -> clientWithoutCredential.createUser("ana.perez@example.com", "Str0ng!Pass", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, failure.error()); + server.verify(); + } + } + + @Nested + @DisplayName("The JWT decoder accepts an unreachable JWKS URI") + class JwtDecoderIsBuiltAnyway { + + private final SecurityConfig config = new SecurityConfig( + new SecurityProperties("https://placeholder.supabase.co/auth/v1", + "https://placeholder.supabase.co/auth/v1/.well-known/jwks.json"), + new ObjectMapper()); + + @Test + @DisplayName("RISK: the decoder is built without ever contacting the JWKS endpoint") + void decoderIsBuiltWithoutContactingTheJwksEndpoint() { + assertDoesNotThrow(config::jwtDecoder, + "a wrong SUPABASE_URL is invisible until the first token arrives"); + } + + @Test + @DisplayName("The misconfiguration only surfaces when a token is decoded") + void failureSurfacesOnFirstDecode() { + JwtDecoder decoder = config.jwtDecoder(); + + assertThrows(JwtException.class, () -> decoder.decode("not-a-real-token")); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/support/BeanValidationSupport.java b/src/test/java/com/codefactory/bookingplatform/support/BeanValidationSupport.java new file mode 100644 index 0000000..efaa9fe --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/support/BeanValidationSupport.java @@ -0,0 +1,62 @@ +package com.codefactory.bookingplatform.support; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Shared Jakarta Validator for the DTO boundary tests. Building the factory is + * expensive, so it is created once and reused by every DTO test class. + */ +public final class BeanValidationSupport { + + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); + private static final Validator VALIDATOR = FACTORY.getValidator(); + + private BeanValidationSupport() { + } + + public static Validator validator() { + return VALIDATOR; + } + + /** Every violation message raised on {@code property}, empty when the field is accepted. */ + public static Set messagesFor(T bean, String property) { + return VALIDATOR.validate(bean).stream() + .filter(v -> property.equals(v.getPropertyPath().toString())) + .map(ConstraintViolation::getMessage) + .collect(Collectors.toSet()); + } + + /** Names of every property that raised at least one violation. */ + public static Set invalidProperties(T bean) { + return VALIDATOR.validate(bean).stream() + .map(v -> v.getPropertyPath().toString()) + .collect(Collectors.toSet()); + } + + public static String repeat(char character, int length) { + return String.valueOf(character).repeat(length); + } + + /** + * A syntactically valid email of exactly {@code totalLength} characters. + * The local part is capped at the 64 characters RFC 5321 allows and the + * domain is split into labels short enough to stay valid, so the only + * constraint the result can ever trip is the length one. + * Valid for totalLength between 131 and 190. + */ + public static String emailOfLength(int totalLength) { + int localPart = 64; + int secondLabel = totalLength - 130; + return repeat('a', localPart) + "@" + repeat('b', 60) + "." + repeat('c', secondLabel) + ".com"; + } + + public static String digits(int length) { + return repeat('7', length); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/support/JwtIntegrationTestBase.java b/src/test/java/com/codefactory/bookingplatform/support/JwtIntegrationTestBase.java new file mode 100644 index 0000000..25cd43b --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/support/JwtIntegrationTestBase.java @@ -0,0 +1,77 @@ +package com.codefactory.bookingplatform.support; + +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.auth.infrastructure.persistence.LoginAttemptJpaRepository; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration base for the tests that need the real JWT pipeline. + * + *

{@link PostgresIntegrationTestBase} gives us PostgreSQL; on top of that this base starts a + * local JWKS endpoint and repoints {@code app.security.jwt-issuer} / {@code app.security.jwks-uri} + * at it, so the {@code JwtDecoder} bean built by {@code SecurityConfig} resolves keys, checks + * signatures, {@code exp} and {@code iss} for real instead of being short-circuited by the + * {@code SecurityMockMvcRequestPostProcessors.jwt()} helper. + * + *

It also installs {@link SimulatedIdentityProvider} over the mocked {@link IdentityProviderPort} + * so the external provider behaves like a provider (one-time links, sessions) rather than like a + * per-call stub. + */ +public abstract class JwtIntegrationTestBase extends PostgresIntegrationTestBase { + + protected static final LocalJwksServer JWKS_SERVER = LocalJwksServer.start(); + + @DynamicPropertySource + static void registerJwksProperties(DynamicPropertyRegistry registry) { + registry.add("app.security.jwt-issuer", JWKS_SERVER::issuer); + registry.add("app.security.jwks-uri", JWKS_SERVER::jwksUri); + } + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ClientJpaRepository clientJpaRepository; + + @Autowired + protected LoginAttemptJpaRepository loginAttemptJpaRepository; + + @MockitoBean + protected IdentityProviderPort identityProviderPort; + + protected SimulatedIdentityProvider identityProvider; + + @BeforeEach + void resetStateAndInstallProvider() { + clientJpaRepository.deleteAll(); + loginAttemptJpaRepository.deleteAll(); + identityProvider = new SimulatedIdentityProvider(JWKS_SERVER.jwks()); + identityProvider.install(identityProviderPort); + } + + protected static TestJwks jwks() { + return JWKS_SERVER.jwks(); + } + + /** Registration payload for the reference client used across the acceptance tests. */ + protected static String registrationPayload(String email, String document) { + return """ + { + "fullName": "Ana Maria Perez", + "document": "%s", + "birthDate": "1995-04-10", + "email": "%s", + "phone": "+573001234567", + "city": "Bogota", + "notificationChannel": "EMAIL", + "password": "Str0ng!Pass" + } + """.formatted(document, email); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/support/LocalJwksServer.java b/src/test/java/com/codefactory/bookingplatform/support/LocalJwksServer.java new file mode 100644 index 0000000..7da918a --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/support/LocalJwksServer.java @@ -0,0 +1,71 @@ +package com.codefactory.bookingplatform.support; + +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A throwaway HTTP server that publishes a JWK set, standing in for the Supabase + * {@code /auth/v1/.well-known/jwks.json} endpoint. + * + *

It exists so the integration tests can point {@code app.security.jwks-uri} at a real URL and + * let {@code NimbusJwtDecoder} fetch the keys over HTTP, which is the only way to exercise + * {@code SecurityConfig.jwtDecoder()} end to end (the Spring Security test post-processors bypass + * the decoder entirely). Uses the JDK's own {@code com.sun.net.httpserver}; no extra dependency. + */ +public final class LocalJwksServer { + + public static final String JWKS_PATH = "/auth/v1/.well-known/jwks.json"; + + private final HttpServer server; + private final TestJwks jwks; + private final AtomicInteger requestCount = new AtomicInteger(); + + private LocalJwksServer(HttpServer server, TestJwks jwks) { + this.server = server; + this.jwks = jwks; + } + + public static LocalJwksServer start() { + try { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + String issuer = "http://127.0.0.1:" + server.getAddress().getPort() + "/auth/v1"; + LocalJwksServer instance = new LocalJwksServer(server, new TestJwks(issuer)); + server.createContext(JWKS_PATH, exchange -> { + instance.requestCount.incrementAndGet(); + byte[] body = instance.jwks.jwkSetJson().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.setExecutor(null); + server.start(); + return instance; + } catch (IOException ex) { + throw new IllegalStateException("Could not start the local JWKS server", ex); + } + } + + public TestJwks jwks() { + return jwks; + } + + public String issuer() { + return jwks.issuer(); + } + + public String jwksUri() { + return "http://127.0.0.1:" + server.getAddress().getPort() + JWKS_PATH; + } + + /** How many times the decoder actually went to the network for the keys. */ + public int requestCount() { + return requestCount.get(); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/support/SimulatedIdentityProvider.java b/src/test/java/com/codefactory/bookingplatform/support/SimulatedIdentityProvider.java new file mode 100644 index 0000000..850583f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/support/SimulatedIdentityProvider.java @@ -0,0 +1,242 @@ +package com.codefactory.bookingplatform.support; + +import com.codefactory.bookingplatform.auth.domain.model.AppRole; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; + +/** + * In-memory stand-in for Supabase Auth (GoTrue), installed on top of the {@code @MockitoBean} + * of {@link IdentityProviderPort} that the integration tests already use. + * + *

The existing ITs stub the port call by call, which is enough to check one branch but cannot + * express behaviour that depends on history. The acceptance criteria "recuperación de + * contraseña con enlace de un solo uso" (HU-021) and "reenvío del correo de verificación con + * enlace vigente" (HU-001) are exactly that: whether a token is accepted depends on whether it was + * already redeemed. So this fake keeps the state a real provider keeps: + * + *

    + *
  • users, with their password and whether the email is confirmed;
  • + *
  • one-time email-verification tokens, burned on redemption;
  • + *
  • one-time password-recovery tokens, burned on redemption;
  • + *
  • issued access tokens and which of them have been revoked by {@code signOut}.
  • + *
+ * + *

Access tokens are real RS256 JWTs signed with the key published by {@link LocalJwksServer}, + * so a token handed out by {@code /login} is a token the production {@code JwtDecoder} accepts. + */ +public final class SimulatedIdentityProvider { + + /** Prefix of the {@code token_hash} that travels in the verification email link. */ + public static final String EMAIL_TOKEN_PREFIX = "email-otp-"; + /** Prefix of the {@code token_hash} that travels in the recovery email link. */ + public static final String RECOVERY_TOKEN_PREFIX = "recovery-otp-"; + + private final TestJwks jwks; + + private final Map userIdsByEmail = new ConcurrentHashMap<>(); + private final Map passwordsByEmail = new ConcurrentHashMap<>(); + private final Map rolesByEmail = new ConcurrentHashMap<>(); + private final Set confirmedEmails = ConcurrentHashMap.newKeySet(); + + private final Map liveEmailTokens = new ConcurrentHashMap<>(); + private final Map liveRecoveryTokens = new ConcurrentHashMap<>(); + private final Set redeemedTokens = ConcurrentHashMap.newKeySet(); + + private final Map emailByAccessToken = new ConcurrentHashMap<>(); + private final Set revokedAccessTokens = ConcurrentHashMap.newKeySet(); + + private int emailTokenSequence; + private int recoveryTokenSequence; + + public SimulatedIdentityProvider(TestJwks jwks) { + this.jwks = jwks; + } + + /** Wires every port method to this fake. Call it from {@code @BeforeEach}. */ + public void install(IdentityProviderPort mock) { + doAnswer(invocation -> createUser( + invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))) + .when(mock).createUser(anyString(), anyString(), any(AppRole.class)); + + doAnswer(invocation -> { + deleteUser(invocation.getArgument(0)); + return null; + }).when(mock).deleteUser(any(UUID.class)); + + doAnswer(invocation -> requestPasswordToken(invocation.getArgument(0), invocation.getArgument(1))) + .when(mock).requestPasswordToken(anyString(), anyString()); + + doAnswer(invocation -> verifyEmailToken(invocation.getArgument(0))) + .when(mock).verifyEmailToken(anyString()); + + doAnswer(invocation -> { + resendSignupVerification(invocation.getArgument(0)); + return null; + }).when(mock).resendSignupVerification(anyString()); + + doAnswer(invocation -> { + sendPasswordRecovery(invocation.getArgument(0)); + return null; + }).when(mock).sendPasswordRecovery(anyString()); + + doAnswer(invocation -> { + resetPasswordWithToken(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(mock).resetPasswordWithToken(anyString(), anyString()); + + doAnswer(invocation -> { + signOut(invocation.getArgument(0)); + return null; + }).when(mock).signOut(anyString()); + } + + // --- provider behaviour ------------------------------------------------- + + private UUID createUser(String email, String password, AppRole role) { + String key = normalize(email); + if (userIdsByEmail.containsKey(key)) { + throw new UpstreamAuthException(UpstreamAuthError.USER_ALREADY_EXISTS, "User already registered"); + } + UUID userId = UUID.randomUUID(); + userIdsByEmail.put(key, userId); + passwordsByEmail.put(key, password); + rolesByEmail.put(key, role); + mintEmailToken(key); + return userId; + } + + private void deleteUser(UUID userId) { + userIdsByEmail.entrySet().removeIf(entry -> entry.getValue().equals(userId)); + } + + private AuthTokens requestPasswordToken(String email, String password) { + String key = normalize(email); + UUID userId = userIdsByEmail.get(key); + if (userId == null || !passwordsByEmail.get(key).equals(password)) { + throw new UpstreamAuthException(UpstreamAuthError.INVALID_CREDENTIALS, "Invalid login credentials"); + } + if (!confirmedEmails.contains(key)) { + throw new UpstreamAuthException(UpstreamAuthError.EMAIL_NOT_CONFIRMED, "Email not confirmed"); + } + String accessToken = jwks.accessToken(userId, key, rolesByEmail.get(key).name()); + emailByAccessToken.put(accessToken, key); + return new AuthTokens(accessToken, "refresh-" + UUID.randomUUID(), "bearer", 900); + } + + private ConfirmedUser verifyEmailToken(String tokenHash) { + String email = liveEmailTokens.get(tokenHash); + if (email == null) { + // Already redeemed tokens land here too: a one-time link is single use. + throw new UpstreamAuthException(UpstreamAuthError.TOKEN_INVALID, + redeemedTokens.contains(tokenHash) + ? "Email link is invalid or has already been used" + : "Email link is invalid or has expired"); + } + liveEmailTokens.remove(tokenHash); + redeemedTokens.add(tokenHash); + confirmedEmails.add(email); + return new ConfirmedUser(userIdsByEmail.get(email), email); + } + + private void resendSignupVerification(String email) { + String key = normalize(email); + if (!userIdsByEmail.containsKey(key)) { + throw new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "User not found"); + } + mintEmailToken(key); + } + + private void sendPasswordRecovery(String email) { + String key = normalize(email); + if (!userIdsByEmail.containsKey(key)) { + throw new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "User not found"); + } + liveRecoveryTokens.values().removeIf(key::equals); + String token = RECOVERY_TOKEN_PREFIX + (++recoveryTokenSequence); + liveRecoveryTokens.put(token, key); + } + + private void resetPasswordWithToken(String tokenHash, String newPassword) { + String email = liveRecoveryTokens.get(tokenHash); + if (email == null) { + throw new UpstreamAuthException(UpstreamAuthError.TOKEN_INVALID, + redeemedTokens.contains(tokenHash) + ? "Recovery link is invalid or has already been used" + : "Recovery link is invalid or has expired"); + } + liveRecoveryTokens.remove(tokenHash); + redeemedTokens.add(tokenHash); + passwordsByEmail.put(email, newPassword); + // A real provider drops every active session when the password changes. + emailByAccessToken.forEach((token, owner) -> { + if (owner.equals(email)) { + revokedAccessTokens.add(token); + } + }); + } + + private void signOut(String accessToken) { + revokedAccessTokens.add(accessToken); + } + + private void mintEmailToken(String email) { + liveEmailTokens.values().removeIf(email::equals); + String token = EMAIL_TOKEN_PREFIX + (++emailTokenSequence); + liveEmailTokens.put(token, email); + } + + // --- test-side inspection ---------------------------------------------- + + /** The {@code token_hash} currently travelling in the verification link for that email. */ + public String currentEmailToken(String email) { + return findToken(liveEmailTokens, email) + .orElseThrow(() -> new IllegalStateException("No live verification token for " + email)); + } + + /** The {@code token_hash} currently travelling in the recovery link for that email. */ + public String currentRecoveryToken(String email) { + return findToken(liveRecoveryTokens, email) + .orElseThrow(() -> new IllegalStateException("No live recovery token for " + email)); + } + + public boolean isTokenRedeemed(String tokenHash) { + return redeemedTokens.contains(tokenHash); + } + + public boolean isSessionRevoked(String accessToken) { + return revokedAccessTokens.contains(accessToken); + } + + public boolean isEmailConfirmed(String email) { + return confirmedEmails.contains(normalize(email)); + } + + public UUID userIdOf(String email) { + return userIdsByEmail.get(normalize(email)); + } + + private static Optional findToken(Map tokens, String email) { + String key = normalize(email); + return tokens.entrySet().stream() + .filter(entry -> entry.getValue().equals(key)) + .map(Map.Entry::getKey) + .findFirst(); + } + + private static String normalize(String email) { + return email.toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/support/TestJwks.java b/src/test/java/com/codefactory/bookingplatform/support/TestJwks.java new file mode 100644 index 0000000..0f4aca2 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/support/TestJwks.java @@ -0,0 +1,113 @@ +package com.codefactory.bookingplatform.support; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +/** + * Minimal JWKS/JWT toolbox for the integration tests (HU-021). + * + *

The production {@code SecurityConfig.jwtDecoder()} validates the signature against the + * JWKS published by Supabase and then checks {@code exp}/{@code nbf} and {@code iss}. Tests that + * want to exercise that decoder for real need two keys: one published in the JWK set (so tokens + * signed with it verify) and one kept out of it (so tokens signed with it fail the signature + * check exactly like a forged token would). + */ +public final class TestJwks { + + public static final String KEY_ID = "bookingplatform-test-key"; + + private final RSAKey publishedKey; + private final RSAKey strangerKey; + private final String issuer; + + public TestJwks(String issuer) { + this.issuer = issuer; + try { + this.publishedKey = new RSAKeyGenerator(2048) + .keyID(KEY_ID) + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .generate(); + this.strangerKey = new RSAKeyGenerator(2048) + .keyID(KEY_ID) // same kid on purpose: only the signature tells them apart + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .generate(); + } catch (JOSEException ex) { + throw new IllegalStateException("Could not generate the test RSA keys", ex); + } + } + + public String issuer() { + return issuer; + } + + /** The public JWK set, exactly as the identity provider would publish it. */ + public String jwkSetJson() { + return new JWKSet(publishedKey.toPublicJWK()).toString(); + } + + /** A well formed, currently valid access token for the given user. */ + public String accessToken(UUID userId, String email, String role) { + return sign(publishedKey, claims(userId, email, role, issuer, Instant.now().minusSeconds(5), + Instant.now().plus(Duration.ofMinutes(15)))); + } + + /** Valid signature and issuer, but already expired: must fail {@code JwtTimestampValidator}. */ + public String expiredAccessToken(UUID userId, String email, String role) { + Instant issuedAt = Instant.now().minus(Duration.ofHours(2)); + return sign(publishedKey, claims(userId, email, role, issuer, issuedAt, issuedAt.plus(Duration.ofMinutes(15)))); + } + + /** Valid signature, but minted by a different issuer: must fail {@code JwtIssuerValidator}. */ + public String tokenFromAnotherIssuer(UUID userId, String email, String role) { + return sign(publishedKey, claims(userId, email, role, "https://evil.example.com/auth/v1", + Instant.now().minusSeconds(5), Instant.now().plus(Duration.ofMinutes(15)))); + } + + /** Right claims and right {@code kid}, signed with a key that is not in the JWK set. */ + public String tokenWithForgedSignature(UUID userId, String email, String role) { + return sign(strangerKey, claims(userId, email, role, issuer, Instant.now().minusSeconds(5), + Instant.now().plus(Duration.ofMinutes(15)))); + } + + private static JWTClaimsSet claims(UUID userId, String email, String role, String issuer, + Instant issuedAt, Instant expiresAt) { + return new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(userId.toString()) + .audience("authenticated") + .issueTime(Date.from(issuedAt)) + .expirationTime(Date.from(expiresAt)) + .claim("email", email) + .claim("role", "authenticated") + .claim("app_metadata", Map.of("role", role)) + .claim("user_metadata", Map.of("role", "ADMIN")) // must be ignored by the converter + .build(); + } + + private static String sign(RSAKey key, JWTClaimsSet claims) { + try { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(), claims); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } catch (JOSEException ex) { + throw new IllegalStateException("Could not sign the test token", ex); + } + } +}