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");
+ }
+ }
+
+ /** Answers whatever request arrives with the given status and body, then runs the endpoint. */
+ private UpstreamAuthException callExpectingFailure(Endpoint endpoint, int status, String body) {
+ server.expect(request -> { }).andRespond(withStatus(HttpStatusCode.valueOf(status)).body(body));
+ UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> invoke(endpoint));
+ server.verify();
+ return ex;
+ }
+
+ // ---------------------------------------------------------------------
+ // mapError: decision table (black box)
+ // ---------------------------------------------------------------------
+
+ static Stream 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}. 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. 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. Black box: the whole enum is swept with {@code values()} so a code added later without a
+ * status or a message fails here rather than in production; the status of each code is then pinned
+ * against its documented semantics with a decision table. 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.
Black box: the whole enum is swept with {@code values()} so a code added later without a - * status or a message fails here rather than in production; the status of each code is then pinned - * against its documented semantics with a decision table.
+ *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} declares an HTTP status") - @EnumSource(ErrorCode.class) - @DisplayName("Every error code declares a non-null HTTP status, because the handler builds the response from it") - void everyCodeDeclaresAStatus(ErrorCode code) { - assertNotNull(code.status(), () -> code.name() + " has no HTTP status"); - } - - @ParameterizedTest(name = "{0} declares a default message") - @EnumSource(ErrorCode.class) - @DisplayName("Every error code declares a non-blank default message, which is what the client reads when no specific one is given") - void everyCodeDeclaresAMessage(ErrorCode code) { - assertNotNull(code.defaultMessage(), () -> code.name() + " has no default message"); - assertFalse(code.defaultMessage().isBlank(), () -> code.name() + " has a blank default message"); - } - - @ParameterizedTest(name = "{0} is an error status") - @EnumSource(ErrorCode.class) - @DisplayName("Every error code maps to a 4xx or 5xx status, never to a success or a redirect") - void everyCodeMapsToAnErrorStatus(ErrorCode code) { - assertTrue(code.status().isError(), - () -> code.name() + " maps to " + code.status() + ", which is not an error status"); - } - @ParameterizedTest(name = "{0} -> {1}") @CsvSource({ "VALIDATION_ERROR, BAD_REQUEST", @@ -74,41 +42,9 @@ void statusMatchesTheSemanticsOfTheCode(ErrorCode code, HttpStatus expectedStatu } @Test - @DisplayName("The decision table above covers every code in the catalogue, so a new code cannot slip through untested") + @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"); } - - @Test - @DisplayName("Only the upstream failure and the internal error are server faults, every other code blames the caller") - void onlyTwoCodesAreServerFaults() { - assertEquals(List.of(ErrorCode.UPSTREAM_AUTH_ERROR, ErrorCode.INTERNAL_ERROR), - Arrays.stream(ErrorCode.values()).filter(c -> c.status().is5xxServerError()).toList()); - } - - @Test - @DisplayName("An upstream failure is reported as a gateway problem, not as a client mistake") - void upstreamFailureIsAGatewayProblem() { - assertTrue(ErrorCode.UPSTREAM_AUTH_ERROR.status().is5xxServerError()); - } - - @Test - @DisplayName("A locked account is reported as a rate-limit status so clients back off instead of retrying") - void lockedAccountIsRateLimited() { - assertEquals(HttpStatus.TOO_MANY_REQUESTS, ErrorCode.ACCOUNT_LOCKED.status()); - } - - @ParameterizedTest(name = "{0} round-trips through its name") - @EnumSource(ErrorCode.class) - @DisplayName("Every error code can be resolved back from its own name, which is the value sent to the client") - void everyCodeRoundTripsThroughItsName(ErrorCode code) { - assertEquals(code, ErrorCode.valueOf(code.name())); - } - - @Test - @DisplayName("An unknown error code name is rejected rather than resolved to a default") - void unknownCodeNameIsRejected() { - assertThrows(IllegalArgumentException.class, () -> ErrorCode.valueOf("TEAPOT")); - } } From 4fdc57e0b84b5ddbf9b23b392d38ed71041043b6 Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:15:41 -0500 Subject: [PATCH 07/12] fix: translate upstream failures instead of letting them surface as 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight surgical changes, each verified against the whole suite before the next one. No public signature, endpoint path, JSON field name or database schema was touched. Password recovery and password reset rethrew the raw UpstreamAuthException, and nothing handles that type, so the caller got a 500 where the rest of the module answers 429 or 502. The recovery endpoint promises the same answer for every email so it cannot be used to find out who is registered, and a provider rate limit turned that 202 into a 500, which is itself the signal the promise was meant to hide. The translation now lives in the two use cases, mirroring LoginUseCase, rather than in a handler in shared: an UpstreamAuthException handler there would make the shared kernel depend on the auth module and break the ArchUnit rule that keeps it module agnostic. GoTrue answers 404 to an expired or already consumed one-time token, and the 404 rule was evaluated before the expired one, so a stale verification link was reported as USER_NOT_FOUND and the user read "user not found". The expired check now runs first and the bare 404 is classified by the verify branch. Only the two rows of the decision table that documented this defect change; the other thirty-one were verified untouched. A non-numeric or decimal expires_in, and a malformed id, escaped as raw NumberFormatException and IllegalArgumentException, outside the two exception types the adapter catches, and reached the caller as a 500. Both now come out as UNAVAILABLE, keeping the original as the cause. Role and error-code normalisation now pass Locale.ROOT. These are protocol values, not display text: under a Turkish default locale "admin" upper cases to "ADMİN" and every hasRole("ADMIN") check fails silently. The two exceptions declare serialVersionUID. BusinessException holds its details in a LinkedHashMap field rather than a Map, which is what made the serialisable-field warning legitimate; the published view is still an unmodifiable copy. The email of a confirmed user is deliberately left tolerant. Nothing reads it: ConfirmEmailUseCase resolves the client by id. Requiring it would turn a response missing that field into a 502 over a value that is discarded, so it is returned as null and never as the four-character string "null". --- .../application/PasswordRecoveryUseCase.java | 17 ++- .../application/PasswordResetUseCase.java | 13 +- .../domain/model/UpstreamAuthException.java | 2 + .../infrastructure/supabase/GoTrueClient.java | 60 +++++++-- .../shared/config/SecurityConfig.java | 4 +- .../config/SupabaseJwtAuthConverter.java | 6 +- .../shared/error/BusinessException.java | 16 ++- .../shared/error/GlobalExceptionHandler.java | 6 +- .../PasswordRecoveryUseCaseTest.java | 55 +++++++-- .../application/PasswordResetUseCaseTest.java | 52 +++++--- .../model/UpstreamAuthExceptionTest.java | 28 +++++ .../supabase/GoTrueClientTest.java | 115 ++++++++++++++---- .../shared/config/SecurityConfigTest.java | 19 +++ .../config/SupabaseJwtAuthConverterTest.java | 19 +++ .../shared/error/BusinessExceptionTest.java | 57 +++++++++ .../error/GlobalExceptionHandlerTest.java | 16 +++ 16 files changed, 417 insertions(+), 68 deletions(-) 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 MapRegistration -> 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: + * + *
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/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/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 " {@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:
+ *
+ * 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 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);
+ }
+ }
+}
From ca4938d0757e06034f001192d69200b7829cfa52 Mon Sep 17 00:00:00 2001
From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com>
Date: Tue, 22 Sep 2026 03:20:24 -0500
Subject: [PATCH 09/12] test: prove the application starts, and find out what
happens when it cannot
Nothing verified that the Spring context loads. A broken bean, a missing
property or a schema drift would have been found on deploy, not on build.
The cloud profile is now started against a PostgreSQL container with
docs/database/schema.sql applied and ddl-auto=validate. Green means the
committed DDL and the JPA mapping agree, which is the check that decides
whether the service boots on Render at all.
Every environment variable the deployment declares is exercised absent, empty
and present, with a controlled environment source so the result does not
depend on the machine running the tests. Three of them turn out to be read by
nobody, and the identity credential turns out not to be required at startup at
all: the application comes up without it and fails on the first request, where
a login reports INVALID_CREDENTIALS because that is how the provider's 401 is
classified. The operator reads "invalid email or password" and looks in the
wrong place. That path is pinned end to end.
The actuator is pinned as it is actually exposed: which endpoints answer,
which are public, and what the health groups contain. The readiness group,
which is the one Render polls, does not include the database.
The application is also started for real, on Tomcat with a random port and a
PostgreSQL container, and answered over HTTP: health, the OpenAPI document, a
public endpoint, a protected one without a token, and the trace header.
96 new tests. Nothing in src/main or pom.xml was touched; the fixes those
findings call for are written up, not applied.
---
.../startup/ActuatorHealthIT.java | 127 +++++
.../startup/ApplicationContextStartupIT.java | 288 ++++++++++++
.../startup/ApplicationStartsIT.java | 110 +++++
.../startup/CloudProfileContextIT.java | 138 ++++++
.../startup/DatabaseDownHealthTest.java | 72 +++
...nvironmentConfigurationResilienceTest.java | 443 ++++++++++++++++++
.../MissingSupabaseCredentialTest.java | 146 ++++++
7 files changed, 1324 insertions(+)
create mode 100644 src/test/java/com/codefactory/bookingplatform/startup/ActuatorHealthIT.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/startup/ApplicationContextStartupIT.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/startup/ApplicationStartsIT.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/startup/DatabaseDownHealthTest.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/startup/EnvironmentConfigurationResilienceTest.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/startup/MissingSupabaseCredentialTest.java
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/ 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 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"));
+ }
+ }
+}
From 667f82266c81d496d259ea69ad91d7f41c38a855 Mon Sep 17 00:00:00 2001
From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com>
Date: Tue, 22 Sep 2026 03:24:37 -0500
Subject: [PATCH 10/12] docs: record the deployment validation and the state of
every defect
The report now says which defects were fixed and which were left alone, and
why. Five are corrected, one was closed deliberately without requiring the
field, and four stay open because closing them means a decision that is not
QA's to take, or a risk not worth running the day before a delivery.
It also records what was checked outside the test suite: packaging, the Docker
image, the compose stack, the cloud profile started against the committed
schema, and twenty-four business scenarios exercised over HTTP against the
running container. Twenty-two behave as the contract says; the two that do not
are the same defect.
Six deployment risks are written up with what happens when each environment
variable is missing. Three of them matter for a live demo: a missing identity
credential lets the application start and makes the login report invalid
credentials, which sends whoever is debugging to the wrong place; CORS is
declared in render.yaml and read by nobody; and the health check Render polls
does not look at the database.
---
docs/qa/informe-pruebas-sprint1.md | 152 +++++++++++++++++++++++++++--
1 file changed, 145 insertions(+), 7 deletions(-)
diff --git a/docs/qa/informe-pruebas-sprint1.md b/docs/qa/informe-pruebas-sprint1.md
index cbbf849..d934f99 100644
--- a/docs/qa/informe-pruebas-sprint1.md
+++ b/docs/qa/informe-pruebas-sprint1.md
@@ -8,7 +8,7 @@ en `pom.xml`.
| Métrica | Antes | Después |
|---|---|---|
-| Pruebas | 48 | **1067** |
+| 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) |
@@ -25,6 +25,16 @@ por debajo de esa cifra. Se fija en 90 y no en el 100 actual para dejar margen a
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
@@ -74,8 +84,25 @@ mensaje interno de la excepción, comprobado pasándole una cadena de conexión
## Defectos encontrados
-No se corrigió ninguno: corregirlos es de otro rol. Las pruebas fijan el comportamiento **actual**
-para que el arreglo sea visible cuando se haga.
+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
@@ -112,10 +139,16 @@ escapa sin mapear.
### D4 — Un correo ausente se convierte en la cadena literal `"null"`
-`GoTrueClient.java:121` hace `String.valueOf(user.get("email"))` sin pasar por `requireField`, al
-contrario que el `id` de la línea 120. Si la respuesta no trae correo, `ConfirmedUser.email()` vale
-`"null"`, cuatro caracteres, y eso viaja como si fuera una dirección. No es una excepción: es
-corrupción silenciosa.
+`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`
@@ -150,6 +183,40 @@ 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é |
@@ -181,6 +248,77 @@ Tres cosas que se sospechaban y **no** son defectos, verificadas expresamente:
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 |
From fb1855af11b56a4ecc7ba22bc305dbe0d3d3cfa2 Mon Sep 17 00:00:00 2001
From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com>
Date: Tue, 22 Sep 2026 03:27:34 -0500
Subject: [PATCH 11/12] test: measure whether the suite detects defects, not
just executes code
JaCoCo reported 100% of instructions, branches, lines and methods. Mutation
analysis with PIT, running the full mutator set, changed the code 1038 ways
and 82 of those changes went unnoticed. Coverage said the code was exercised;
it was not verified.
The domain and the application layer held: LoginUseCase, RegisterClientUseCase,
Client, the two policies, the JWT role converter, the trace filter, the
adapters and the mapper had no survivors at all. The weakness was in the
wiring and at the HTTP edge.
The token gate was the worst of it. The only assertion on the JWT decoder was
that it is a NimbusJwtDecoder, which passes just as well if the decoder accepts
every algorithm, validates no expiry and trusts any issuer. It is now tested
against a real JWKS endpoint on the loopback with tokens signed in the test:
ES256 and RS256 accepted, HS256 refused, expired refused, foreign issuer
refused.
The whole security filter chain was invisible to the analysis. Spring caches
the test context, so the chain is built once per JVM and only the first test
was credited with covering it; permitAll, anyRequest().authenticated() and
build() could all be removed unnoticed. The chain is now built per test and
39 mutants die against assertions that already existed.
One idiom is worth spreading: jsonPath("$.password").doesNotExist() passes for
a field that is present with a null value, so a test meant to prove the
password never leaves the service could not see the field being sent.
Every surviving mutant was attacked with a test before being called
equivalent; the claimed 111 shrank to 17, each justified individually. The
analysis also exposed four pieces of production code that no test can
distinguish because they do nothing: the CORS customiser with no source bean,
a guard that duplicates what the mapping below it already returns, five
redundant contentType calls and a title that ProblemDetail already derives.
Business scope 91.7% -> 98.3%. Full scope 89.3% -> 95.7%. A 95% threshold is
configured and passing. The run takes two minutes, so it belongs in CI.
---
pom.xml | 89 +++++++++
.../auth/api/AuthControllerTest.java | 129 +++++++++++++
.../UserProvisioningServiceTest.java | 23 +++
.../supabase/GoTrueClientTest.java | 97 ++++++++++
.../api/RegistrationControllerTest.java | 122 +++++++++++++
.../config/SecurityConfigJwtDecoderTest.java | 171 ++++++++++++++++++
.../shared/config/SecurityConfigTest.java | 72 ++++++++
.../shared/config/SecurityConfigWebTest.java | 70 ++++++-
8 files changed, 772 insertions(+), 1 deletion(-)
create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerTest.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerTest.java
create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigJwtDecoderTest.java
diff --git a/pom.xml b/pom.xml
index 2408787..403ce50 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,6 +22,8 @@
+ *
+ *
+ *
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 index eeb531c..cd4a0f9 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java @@ -1,10 +1,12 @@ 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; @@ -15,9 +17,12 @@ 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; @@ -161,6 +166,73 @@ 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 index 123b377..3eb0e90 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java @@ -21,15 +21,22 @@ 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; @@ -39,7 +46,9 @@ 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; @@ -51,7 +60,8 @@ * unauthenticated call gets back, and how the JWT is turned into authorities. */ @WebMvcTest(controllers = {AuthController.class, RegistrationController.class}) -@Import({SecurityConfig.class, SupabaseJwtAuthConverter.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", @@ -225,4 +235,62 @@ 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")); + } } From 64d3abb4f0e3b96d76d8446178e0615b83161fa9 Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:40:01 -0500 Subject: [PATCH 12/12] test: strengthen four tests that could not fail, and prove they now can An adversarial review broke the production code on purpose and checked which tests stayed green. Four did not protect anything, and one of them was hiding a real defect. Swapping the context literal that GoTrueClient hands its error mapper, so that a recovery failure is classified as a login failure, went unnoticed by all 1046 unit tests and all 21 integration tests. The decision table of thirty seven rows only asserted the resulting error, and its helper matched the request with an empty matcher, so nothing anchored which context each endpoint passes or even which path it calls. Both are now anchored: every row verifies the path, and a new case pins the context of all eight endpoints through the one status that reaches the default arm and names it. PasswordPolicy's isValid was compared against violations().isEmpty(), which is what isValid returns. The assertion was X == X and stayed green with the whole policy disabled. It is replaced by a table with the verdict written down rather than computed. The clock test claimed in its name to detect a frozen clock and did not: a clock frozen at the current instant is still after one minute ago. Reading it twice and waiting for it to move was the first fix and turned out to be flaky, because the system clock resolution does not advance within the loop on Windows. Four clock tests collapse into one that compares against Clock.systemUTC(), which rejects a fixed, an offset and a host-zone clock at once and is deterministic. The timestamp assertion was assertNotNull, which passes for any string. It now parses the value and checks it belongs to this response. Each of the four was verified by reapplying the mutation it is meant to catch and confirming the build turns red, then reverting. The one assertion removed outright was assertNotNull(new JpaAuditingConfig()), which no code can fail. 1018 unit tests plus 98 integration, green. Coverage unchanged at 100%. --- .../domain/service/PasswordPolicyTest.java | 25 +++++++--- .../supabase/GoTrueClientTest.java | 50 ++++++++++++++++++- .../shared/config/ConfigurationBeansTest.java | 34 +++---------- .../error/GlobalExceptionHandlerTest.java | 11 +++- 4 files changed, 84 insertions(+), 36 deletions(-) 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 1065d85..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 @@ -273,12 +273,25 @@ static Stream