From 54e405395f94b85b912808726281b85d98577e87 Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:23:56 -0500 Subject: [PATCH 01/12] build: measure coverage with JaCoCo The project had no coverage measurement, so there was no way to tell which branches the suite actually exercises. The agent instruments the unit test run and the report lands in target/site/jacoco. Records, enums, the Spring entry point, the @ConfigurationProperties holders and the DTOs are excluded: they carry no branches of their own, so counting them raises the percentage without saying anything about the behaviour under test. Baseline on this commit: 20.1% of instructions, 25.0% of branches. --- pom.xml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pom.xml b/pom.xml index 9644f86..8adf49d 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ 3.1.1 1.4.1 1.21.4 + 0.8.13 @@ -168,6 +169,41 @@ + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + **/BookingPlatformApplication.class + **/*Properties.class + **/config/OpenApiConfig.class + **/dto/** + + + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + org.apache.maven.plugins maven-compiler-plugin From 90e2075bc2f3f8d9800069a7eb9b499e75bf9c7e Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:38:21 -0500 Subject: [PATCH 02/12] test: cover the auth and identity use cases with boundary and state cases The eight use cases of the two modules had no unit tests at all: the only thing exercising them was the integration flow, which walks the happy path. What was missing is the behaviour at the edges, and that is where the rules live. Techniques, not line filling. Equivalence partitioning and boundary values on the lock threshold (max-1, max, max+1) and on the age rule (the day before, the day of and the day after the eighteenth birthday, plus a leap-day birth). A decision table over every value of UpstreamAuthError, to pin down which failures count towards the lock and which do not. The full 3x3 state transition table of Client, with the four cells that must reject and the two that are idempotent. Interactions are asserted too, because several rules are about what must NOT happen: a locked account never reaches the identity provider, a weak password never reaches it either, and an unknown email in the resend flow neither calls out nor throws, which is what keeps user enumeration shut. The compensation path is now pinned: if saving the client fails, the auth user is deleted and the original exception travels on. 269 new cases. The nine classes involved go to 100% of instructions and branches. Whole suite: 296 unit tests plus 21 integration, green. Coverage 71.5% -> 76.1% of instructions, 45.5% -> 60.3% of branches. --- pom.xml | 12 +- .../auth/application/LoginUseCaseTest.java | 413 ++++++++++++++++++ .../auth/application/LogoutUseCaseTest.java | 126 ++++++ .../PasswordRecoveryUseCaseTest.java | 135 ++++++ .../application/PasswordResetUseCaseTest.java | 227 ++++++++++ .../UserProvisioningServiceTest.java | 412 +++++++++++++++++ .../application/ConfirmEmailUseCaseTest.java | 202 +++++++++ .../RegisterClientCommandTest.java | 80 ++++ .../RegisterClientUseCaseTest.java | 388 ++++++++++++++++ .../application/RegistrationOutcomeTest.java | 72 +++ .../ResendVerificationUseCaseTest.java | 146 +++++++ .../domain/model/ClientStatusTest.java | 51 +++ .../identity/domain/model/ClientTest.java | 189 ++++++++ .../domain/model/NotificationChannelTest.java | 45 ++ .../domain/service/AgePolicyTest.java | 101 +++++ 15 files changed, 2598 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/application/LoginUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/application/LogoutUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/application/ConfirmEmailUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/application/ResendVerificationUseCaseTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java diff --git a/pom.xml b/pom.xml index 8adf49d..0befdd6 100644 --- a/pom.xml +++ b/pom.xml @@ -195,9 +195,19 @@ prepare-agent + report - test + verify report diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/LoginUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/LoginUseCaseTest.java new file mode 100644 index 0000000..2853daf --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/LoginUseCaseTest.java @@ -0,0 +1,413 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.auth.domain.port.LoginAttemptRepository; +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the login business rules (HU-021). + * + * Techniques applied, declared per group: + * - Boundary value analysis on the failed-attempt lock threshold. + * - Equivalence partitioning and decision tables on which upstream errors count + * as a failed attempt, and on the upstream-error to ErrorCode mapping. + * - Branch and condition coverage: both operands of the compound OR in the catch + * block, every arm of the mapping switch, both sides of the lock guard. + * - Interaction verification: the provider must not be reached while locked, and + * the attempt must be recorded with the normalized email. + */ +class LoginUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-09-22T10:00:00Z"); + private static final int MAX_FAILED_ATTEMPTS = 3; + private static final Duration LOCK_WINDOW = Duration.ofMinutes(15); + + private IdentityProviderPort identityProvider; + private LoginAttemptRepository loginAttemptRepository; + private LoginUseCase useCase; + + private final AuthTokens tokens = new AuthTokens("access", "refresh", "bearer", 3600L); + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + loginAttemptRepository = mock(LoginAttemptRepository.class); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + useCase = new LoginUseCase(identityProvider, loginAttemptRepository, + new LoginLockPolicy(MAX_FAILED_ATTEMPTS, LOCK_WINDOW), clock); + } + + /** Recent failures inside the sliding window, one per minute before now. */ + private void givenRecentFailures(int count) { + List failures = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + failures.add(NOW.minus(Duration.ofMinutes(i))); + } + when(loginAttemptRepository.findFailuresSince(anyString(), any(Instant.class))).thenReturn(failures); + } + + private UpstreamAuthException upstream(UpstreamAuthError error) { + return new UpstreamAuthException(error, "gotrue said " + error); + } + + // ----------------------------------------------------------------- // + // Black box: boundary value analysis, maxFailedAttempts -1 / = / +1 // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Lock threshold (boundary value analysis around maxFailedAttempts = 3)") + class LockThreshold { + + @Test + @DisplayName("One failure below the threshold still lets the user log in") + void oneBelowThresholdIsNotLocked() { + givenRecentFailures(MAX_FAILED_ATTEMPTS - 1); + when(identityProvider.requestPasswordToken("ana@example.com", "pwd")).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + + @Test + @DisplayName("Exactly maxFailedAttempts recent failures lock the account") + void exactlyAtThresholdIsLocked() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.ACCOUNT_LOCKED, ex.errorCode()); + } + + @Test + @DisplayName("One failure above the threshold keeps the account locked") + void oneAboveThresholdIsLocked() { + givenRecentFailures(MAX_FAILED_ATTEMPTS + 1); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.ACCOUNT_LOCKED, ex.errorCode()); + } + + @Test + @DisplayName("An account with no previous failures is never locked") + void noFailuresIsNotLocked() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + + @Test + @DisplayName("Failures older than the lock window do not lock the account") + void staleFailuresOutsideWindowDoNotLock() { + when(loginAttemptRepository.findFailuresSince(anyString(), any(Instant.class))) + .thenReturn(List.of(NOW.minus(Duration.ofMinutes(16)), + NOW.minus(Duration.ofMinutes(17)), + NOW.minus(Duration.ofMinutes(18)))); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + } + + // ----------------------------------------------------------------- // + // White box: the locked branch, plus interaction verification // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Lock response contract") + class LockResponse { + + @Test + @DisplayName("A locked account never reaches the identity provider") + void lockedAccountDoesNotCallProvider() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verifyNoInteractions(identityProvider); + } + + @Test + @DisplayName("A login rejected by the lock is not recorded as a new attempt") + void lockedAccountDoesNotRecordAttempt() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verify(loginAttemptRepository, never()).recordAttempt(anyString(), anyBoolean(), any(Instant.class)); + } + + @Test + @DisplayName("The lock reports retryAfterMinutes as the configured window in minutes") + void lockReportsRetryAfterMinutes() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(String.valueOf(LOCK_WINDOW.toMinutes()), ex.details().get("retryAfterMinutes")); + } + + @Test + @DisplayName("The lock is reported with the ACCOUNT_LOCKED default message") + void lockUsesDefaultMessage() { + givenRecentFailures(MAX_FAILED_ATTEMPTS); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.ACCOUNT_LOCKED.defaultMessage(), ex.getMessage()); + } + } + + // ----------------------------------------------------------------- // + // Black box: equivalence partitions on email casing // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Email normalization (partitions: upper case, mixed case, already lower case)") + class EmailNormalization { + + @ParameterizedTest(name = "[{0}] is queried as [{1}]") + @CsvSource({ + "ANA@EXAMPLE.COM, ana@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com", + "ana@example.com, ana@example.com" + }) + @DisplayName("The email is lowercased before looking up the recent failures") + void emailIsNormalizedBeforeQuery(String rawEmail, String expected) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login(rawEmail, "pwd"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(loginAttemptRepository).findFailuresSince(captor.capture(), any(Instant.class)); + assertEquals(expected, captor.getValue()); + } + + @ParameterizedTest(name = "[{0}] is recorded as [{1}]") + @CsvSource({ + "ANA@EXAMPLE.COM, ana@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com" + }) + @DisplayName("The email is lowercased before recording a successful attempt") + void emailIsNormalizedBeforeRecordingSuccess(String rawEmail, String expected) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login(rawEmail, "pwd"); + + verify(loginAttemptRepository).recordAttempt(eq(expected), anyBoolean(), any(Instant.class)); + } + + @Test + @DisplayName("The email is lowercased before recording a failed attempt") + void emailIsNormalizedBeforeRecordingFailure() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.INVALID_CREDENTIALS)); + + assertThrows(BusinessException.class, () -> useCase.login("ANA@Example.COM", "pwd")); + + verify(loginAttemptRepository).recordAttempt(eq("ana@example.com"), eq(false), any(Instant.class)); + } + + @Test + @DisplayName("The normalized email, not the raw one, is sent to the identity provider") + void normalizedEmailIsSentToProvider() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login("ANA@EXAMPLE.COM", "pwd"); + + verify(identityProvider).requestPasswordToken("ana@example.com", "pwd"); + } + } + + @Nested + @DisplayName("Sliding window query") + class WindowQuery { + + @Test + @DisplayName("Recent failures are looked up from now minus the lock window") + void queriesFailuresSinceNowMinusWindow() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login("ana@example.com", "pwd"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Instant.class); + verify(loginAttemptRepository).findFailuresSince(anyString(), captor.capture()); + assertEquals(NOW.minus(LOCK_WINDOW), captor.getValue()); + } + } + + @Nested + @DisplayName("Successful login (happy path)") + class SuccessfulLogin { + + @Test + @DisplayName("A correct login returns the tokens issued by the identity provider") + void returnsProviderTokens() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + assertSame(tokens, useCase.login("ana@example.com", "pwd")); + } + + @Test + @DisplayName("A correct login is recorded as a successful attempt at the current instant") + void recordsSuccessTrue() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenReturn(tokens); + + useCase.login("ana@example.com", "pwd"); + + verify(loginAttemptRepository).recordAttempt("ana@example.com", true, NOW); + } + } + + // ----------------------------------------------------------------- // + // Decision table: which upstream errors count as a failed attempt // + // (also covers both operands of the compound OR in the catch block) // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Failed-attempt bookkeeping (decision table over UpstreamAuthError)") + class AttemptBookkeeping { + + @ParameterizedTest(name = "{0} counts as a failed attempt") + @EnumSource(value = UpstreamAuthError.class, + names = {"INVALID_CREDENTIALS", "EMAIL_NOT_CONFIRMED"}) + @DisplayName("Credential-related upstream errors count towards the lock") + void credentialErrorsAreRecorded(UpstreamAuthError error) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenThrow(upstream(error)); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verify(loginAttemptRepository).recordAttempt("ana@example.com", false, NOW); + } + + @ParameterizedTest(name = "{0} does not count as a failed attempt") + @EnumSource(value = UpstreamAuthError.class, + names = {"RATE_LIMITED", "UNAVAILABLE", "USER_NOT_FOUND", + "USER_ALREADY_EXISTS", "TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("Infrastructure or unrelated upstream errors never count towards the lock") + void nonCredentialErrorsAreNotRecorded(UpstreamAuthError error) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenThrow(upstream(error)); + + assertThrows(BusinessException.class, () -> useCase.login("ana@example.com", "pwd")); + + verify(loginAttemptRepository, never()).recordAttempt(anyString(), anyBoolean(), any(Instant.class)); + } + } + + // ----------------------------------------------------------------- // + // White box: every arm of the mapUpstream switch // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream error translation (every arm of the mapping switch)") + class UpstreamMapping { + + @ParameterizedTest(name = "{0} is reported to the caller as {1}") + @CsvSource({ + "INVALID_CREDENTIALS, INVALID_CREDENTIALS", + "EMAIL_NOT_CONFIRMED, EMAIL_NOT_CONFIRMED", + "RATE_LIMITED, RATE_LIMITED", + "USER_ALREADY_EXISTS, UPSTREAM_AUTH_ERROR", + "USER_NOT_FOUND, UPSTREAM_AUTH_ERROR", + "TOKEN_INVALID, UPSTREAM_AUTH_ERROR", + "TOKEN_EXPIRED, UPSTREAM_AUTH_ERROR", + "UNAVAILABLE, UPSTREAM_AUTH_ERROR" + }) + @DisplayName("Each upstream error maps to its business error code") + void mapsEachUpstreamError(UpstreamAuthError error, ErrorCode expected) { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())).thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(expected, ex.errorCode()); + } + + @Test + @DisplayName("An unmapped upstream failure keeps the provider message for diagnosis") + void unmappedErrorKeepsUpstreamMessage() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.UNAVAILABLE)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals("gotrue said UNAVAILABLE", ex.getMessage()); + } + + @Test + @DisplayName("A mapped upstream failure does not leak the provider message") + void mappedErrorUsesDefaultMessage() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.INVALID_CREDENTIALS)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertEquals(ErrorCode.INVALID_CREDENTIALS.defaultMessage(), ex.getMessage()); + } + + @Test + @DisplayName("A rejected login carries no extra details to the client") + void failedLoginCarriesNoDetails() { + givenRecentFailures(0); + when(identityProvider.requestPasswordToken(anyString(), anyString())) + .thenThrow(upstream(UpstreamAuthError.INVALID_CREDENTIALS)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.login("ana@example.com", "pwd")); + + assertTrue(ex.details().isEmpty()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/LogoutUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/LogoutUseCaseTest.java new file mode 100644 index 0000000..52c3e9e --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/LogoutUseCaseTest.java @@ -0,0 +1,126 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the logout business rules (HU-021). + * + * Techniques applied: + * - Decision table over UpstreamAuthError: the two "session already dead" values + * are absorbed (logout is idempotent), every other value is escalated. + * - Branch and condition coverage: both operands of the compound OR in the catch + * block, the catch branch itself and the normal flow. + * - Interaction verification: the access token reaches the provider untouched. + */ +class LogoutUseCaseTest { + + private IdentityProviderPort identityProvider; + private LogoutUseCase useCase; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + useCase = new LogoutUseCase(identityProvider); + } + + @Nested + @DisplayName("Happy path") + class HappyPath { + + @Test + @DisplayName("Logout revokes the session at the identity provider with the given access token") + void revokesSessionAtProvider() { + doNothing().when(identityProvider).signOut(anyString()); + + useCase.logout("jwt-access-token"); + + verify(identityProvider).signOut("jwt-access-token"); + } + + @Test + @DisplayName("A revoked session returns normally without raising anything") + void successReturnsQuietly() { + doNothing().when(identityProvider).signOut(anyString()); + + assertDoesNotThrow(() -> useCase.logout("jwt-access-token")); + } + } + + // ----------------------------------------------------------------- // + // Decision table / state transition: session already invalid // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Idempotency (state transition: the session is already dead)") + class AlreadyInvalidSession { + + @ParameterizedTest(name = "{0} is treated as a successful logout") + @EnumSource(value = UpstreamAuthError.class, names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("Logging out an already invalid or expired session succeeds") + void alreadyInvalidSessionIsTreatedAsSuccess(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "session gone")).when(identityProvider).signOut(anyString()); + + assertDoesNotThrow(() -> useCase.logout("stale-token")); + } + } + + // ----------------------------------------------------------------- // + // Exceptional cases: everything the use case must escalate // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream failures") + class UpstreamFailures { + + @ParameterizedTest(name = "{0} is escalated as UPSTREAM_AUTH_ERROR") + @EnumSource(value = UpstreamAuthError.class, + names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any other upstream failure is escalated as an upstream auth error") + void otherUpstreamErrorsAreEscalated(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue down")).when(identityProvider).signOut(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.logout("token")); + + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("An escalated upstream failure keeps the provider message for diagnosis") + void escalatedFailureKeepsUpstreamMessage() { + doThrow(new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out")) + .when(identityProvider).signOut(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.logout("token")); + + assertEquals("gotrue timed out", ex.getMessage()); + } + + @Test + @DisplayName("A non-auth runtime failure is not swallowed by the logout use case") + void unrelatedRuntimeFailurePropagates() { + doThrow(new IllegalStateException("connection reset")).when(identityProvider).signOut(anyString()); + + assertThrows(IllegalStateException.class, () -> useCase.logout("token")); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java new file mode 100644 index 0000000..91e7d61 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java @@ -0,0 +1,135 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for the password recovery request (HU-021). + * + * Techniques applied: + * - Equivalence partitioning on the outcome of the recovery request: known email, + * unknown email (anti-enumeration partition) and provider failure. + * - Decision table over UpstreamAuthError: only USER_NOT_FOUND is absorbed. + * - Branch coverage: normal flow, the catch branch, and both outcomes of the + * USER_NOT_FOUND guard inside it. + * - Interaction verification: the recovery request always reaches the provider. + */ +class PasswordRecoveryUseCaseTest { + + private IdentityProviderPort identityProvider; + private PasswordRecoveryUseCase useCase; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + useCase = new PasswordRecoveryUseCase(identityProvider); + } + + @Nested + @DisplayName("Happy path") + class HappyPath { + + @Test + @DisplayName("A recovery request for a known email is delegated to the identity provider") + void delegatesToProvider() { + doNothing().when(identityProvider).sendPasswordRecovery(anyString()); + + useCase.requestRecovery("ana@example.com"); + + verify(identityProvider).sendPasswordRecovery("ana@example.com"); + } + + @ParameterizedTest(name = "[{0}] is forwarded unchanged") + @ValueSource(strings = {"ana@example.com", "ANA@EXAMPLE.COM", "Ana.Perez@Example.Com"}) + @DisplayName("The email is forwarded to the provider exactly as received") + void forwardsEmailAsReceived(String email) { + doNothing().when(identityProvider).sendPasswordRecovery(anyString()); + + useCase.requestRecovery(email); + + verify(identityProvider).sendPasswordRecovery(email); + } + } + + // ----------------------------------------------------------------- // + // Security rule: no user enumeration through the recovery endpoint // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Anti-enumeration") + class AntiEnumeration { + + @Test + @DisplayName("A recovery request for an unknown email succeeds so accounts cannot be enumerated") + void unknownEmailIsAnsweredAsSuccess() { + doThrow(new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "user not found")) + .when(identityProvider).sendPasswordRecovery(anyString()); + + assertDoesNotThrow(() -> useCase.requestRecovery("ghost@example.com")); + } + + @Test + @DisplayName("An unknown email is still sent to the provider before being absorbed") + void unknownEmailStillReachesProvider() { + doThrow(new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "user not found")) + .when(identityProvider).sendPasswordRecovery(anyString()); + + useCase.requestRecovery("ghost@example.com"); + + verify(identityProvider).sendPasswordRecovery("ghost@example.com"); + } + } + + // ----------------------------------------------------------------- // + // Exceptional cases // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream failures") + class UpstreamFailures { + + @ParameterizedTest(name = "{0} is propagated to the caller") + @EnumSource(value = UpstreamAuthError.class, names = "USER_NOT_FOUND", mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any upstream failure other than an unknown user is propagated") + void otherUpstreamErrorsPropagate(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).sendPasswordRecovery(anyString()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertEquals(error, ex.error()); + } + + @Test + @DisplayName("The propagated failure is the very exception raised by the provider") + void propagatesTheOriginalException() { + UpstreamAuthException raised = + new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many emails"); + doThrow(raised).when(identityProvider).sendPasswordRecovery(anyString()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertSame(raised, ex); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java new file mode 100644 index 0000000..11f98cd --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java @@ -0,0 +1,227 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Unit tests for the password reset with a one-time recovery token (HU-021). + * + * Techniques applied: + * - Boundary value analysis on the password length (7/8 and 72/73 characters). + * - Equivalence partitioning on each password policy rule (uppercase, lowercase, + * digit, special character) plus the null partition. + * - Decision table over UpstreamAuthError: the two token values are translated, + * everything else is propagated. + * - Branch coverage: the weak-password guard, the normal flow, the catch branch + * and both operands of the compound OR inside it. + * - Interaction verification: a weak password must never reach the provider. + */ +class PasswordResetUseCaseTest { + + /** Shortest password that satisfies every rule: upper, lower, digit, special. */ + private static final String VALID_PASSWORD = "Abcdef1!"; + + private IdentityProviderPort identityProvider; + private PasswordResetUseCase useCase; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + useCase = new PasswordResetUseCase(identityProvider); + } + + private static String padded(int totalLength) { + return VALID_PASSWORD + "x".repeat(totalLength - VALID_PASSWORD.length()); + } + + static Stream lengthBoundaries() { + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of("Abcde1!", false), // 7: below minimum + org.junit.jupiter.params.provider.Arguments.of(VALID_PASSWORD, true), // 8: minimum + org.junit.jupiter.params.provider.Arguments.of(padded(9), true), // 9: just inside + org.junit.jupiter.params.provider.Arguments.of(padded(71), true), // 71: just inside + org.junit.jupiter.params.provider.Arguments.of(padded(72), true), // 72: maximum + org.junit.jupiter.params.provider.Arguments.of(padded(73), false)); // 73: above maximum + } + + // ----------------------------------------------------------------- // + // Black box: boundary value analysis on the password length // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Password length (boundary value analysis at 8 and 72 characters)") + class LengthBoundaries { + + @ParameterizedTest(name = "a {0}-character password is accepted = {1}") + @MethodSource("com.codefactory.bookingplatform.auth.application.PasswordResetUseCaseTest#lengthBoundaries") + @DisplayName("Only passwords between 8 and 72 characters are accepted for the reset") + void lengthBoundariesAreEnforced(String password, boolean accepted) { + doNothing().when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + if (accepted) { + assertDoesNotThrow(() -> useCase.resetPassword("token", password)); + } else { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", password)); + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + } + } + + // ----------------------------------------------------------------- // + // Black box: one equivalence partition per password policy rule // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Password strength (one equivalence partition per policy rule)") + class PasswordStrength { + + @ParameterizedTest(name = "[{0}] is rejected: {1}") + @CsvSource({ + "abcdef1!, uppercase", + "ABCDEF1!, lowercase", + "Abcdefg!, digit", + "Abcdefg1, special character" + }) + @DisplayName("A password missing any required character class is rejected as too weak") + void weakPasswordIsRejected(String password, String missingRule) { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @ParameterizedTest(name = "[{0}] never reaches the identity provider") + @ValueSource(strings = {"abcdef1!", "ABCDEF1!", "Abcdefg!", "Abcdefg1", "short1!"}) + @DisplayName("A weak password is rejected before the identity provider is contacted") + void weakPasswordDoesNotReachProvider(String password) { + assertThrows(BusinessException.class, () -> useCase.resetPassword("token", password)); + + verifyNoInteractions(identityProvider); + } + + @ParameterizedTest + @NullSource + @DisplayName("A null password is rejected as too weak instead of blowing up") + void nullPasswordIsRejected(String password) { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @Test + @DisplayName("The rejection lists the violated rules in the violations detail") + void rejectionListsViolations() { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", "abc")); + + String violations = ex.details().get("violations"); + assertTrue(violations.contains("at least 8 characters") + && violations.contains("uppercase") + && violations.contains("digit") + && violations.contains("special"), + "violations detail should list every broken rule but was: " + violations); + } + + @Test + @DisplayName("The rejection uses the PASSWORD_TOO_WEAK default message") + void rejectionUsesDefaultMessage() { + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token", "abc")); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK.defaultMessage(), ex.getMessage()); + } + } + + @Nested + @DisplayName("Happy path") + class HappyPath { + + @Test + @DisplayName("A strong password is sent to the provider together with the recovery token") + void strongPasswordIsDelegated() { + doNothing().when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + useCase.resetPassword("token-hash", VALID_PASSWORD); + + verify(identityProvider).resetPasswordWithToken("token-hash", VALID_PASSWORD); + } + } + + // ----------------------------------------------------------------- // + // Exceptional cases: the recovery token is no longer usable // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Recovery token failures") + class TokenFailures { + + @ParameterizedTest(name = "{0} is reported as VERIFICATION_TOKEN_INVALID") + @EnumSource(value = UpstreamAuthError.class, names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("An invalid or expired recovery token is reported as an invalid verification token") + void badTokenIsTranslated(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "token rejected")) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertEquals(ErrorCode.VERIFICATION_TOKEN_INVALID, ex.errorCode()); + } + + @ParameterizedTest(name = "{0} is propagated to the caller") + @EnumSource(value = UpstreamAuthError.class, + names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any other upstream failure during the reset is propagated") + void otherUpstreamErrorsPropagate(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertEquals(error, ex.error()); + } + + @Test + @DisplayName("The propagated failure is the very exception raised by the provider") + void propagatesTheOriginalException() { + UpstreamAuthException raised = + new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out"); + doThrow(raised).when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertSame(raised, ex); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java new file mode 100644 index 0000000..f063db2 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java @@ -0,0 +1,412 @@ +package com.codefactory.bookingplatform.auth.application; + +import com.codefactory.bookingplatform.auth.domain.model.AppRole; +import com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the auth module facade used by the other business modules. + * + * Techniques applied: + * - Boundary value analysis and equivalence partitioning on the password policy + * applied at provisioning time. + * - Decision table over UpstreamAuthError for mapUpstream: every one of the eight + * enum values is exercised through provisionClientUser, which reaches the switch + * unconditionally. + * - Branch coverage: the weak-password guard, every catch block, the two + * anti-enumeration and token guards, and the compensation catch. + * - Interaction verification: the provider must not be contacted with a weak + * password, and the role must always be CLIENT. + */ +class UserProvisioningServiceTest { + + private static final String VALID_PASSWORD = "Abcdef1!"; + + private IdentityProviderPort identityProvider; + private UserProvisioningService service; + + @BeforeEach + void setUp() { + identityProvider = mock(IdentityProviderPort.class); + service = new UserProvisioningService(identityProvider); + } + + private UpstreamAuthException upstream(UpstreamAuthError error) { + return new UpstreamAuthException(error, "gotrue said " + error); + } + + // ================================================================= // + // provisionClientUser // + // ================================================================= // + + @Nested + @DisplayName("Provisioning a client user: password policy (partitions and boundaries)") + class ProvisioningPasswordPolicy { + + @ParameterizedTest(name = "[{0}] is rejected because it has no {1}") + @CsvSource({ + "abcdef1!, uppercase", + "ABCDEF1!, lowercase", + "Abcdefg!, digit", + "Abcdefg1, special character", + "Abc1!, minimum length" + }) + @DisplayName("A password that breaks the policy is rejected as too weak") + void weakPasswordIsRejected(String password, String brokenRule) { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @ParameterizedTest(name = "[{0}] never reaches the identity provider") + @ValueSource(strings = {"abcdef1!", "ABCDEF1!", "Abcdefg!", "Abcdefg1", "Abc1!"}) + @DisplayName("A weak password is rejected before any user is created upstream") + void weakPasswordDoesNotReachProvider(String password) { + assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", password)); + + verifyNoInteractions(identityProvider); + } + + @ParameterizedTest + @NullSource + @DisplayName("A null password is rejected as too weak instead of blowing up") + void nullPasswordIsRejected(String password) { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", password)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + + @Test + @DisplayName("The rejection lists every broken rule in the violations detail") + void rejectionListsViolations() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", "abc")); + + String violations = ex.details().get("violations"); + assertTrue(violations.contains("at least 8 characters") + && violations.contains("uppercase") + && violations.contains("digit") + && violations.contains("special"), + "violations detail should list every broken rule but was: " + violations); + } + + @Test + @DisplayName("A password at the 8-character minimum is accepted") + void minimumLengthPasswordIsAccepted() { + UUID id = UUID.randomUUID(); + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))).thenReturn(id); + + assertEquals(id, service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + } + + @Test + @DisplayName("A password at the 72-character maximum is accepted") + void maximumLengthPasswordIsAccepted() { + UUID id = UUID.randomUUID(); + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))).thenReturn(id); + + String atMax = VALID_PASSWORD + "x".repeat(72 - VALID_PASSWORD.length()); + + assertEquals(id, service.provisionClientUser("ana@example.com", atMax)); + } + + @Test + @DisplayName("A password one character above the 72-character maximum is rejected") + void aboveMaximumLengthPasswordIsRejected() { + String aboveMax = VALID_PASSWORD + "x".repeat(73 - VALID_PASSWORD.length()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", aboveMax)); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); + } + } + + @Nested + @DisplayName("Provisioning a client user: happy path") + class ProvisioningHappyPath { + + @Test + @DisplayName("A valid signup returns the identifier issued by the identity provider") + void returnsProviderUserId() { + UUID id = UUID.randomUUID(); + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))).thenReturn(id); + + assertEquals(id, service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + } + + @Test + @DisplayName("Self-provisioned users are always created with the CLIENT role") + void alwaysCreatesClientRole() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenReturn(UUID.randomUUID()); + + service.provisionClientUser("ana@example.com", VALID_PASSWORD); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AppRole.class); + verify(identityProvider).createUser(anyString(), anyString(), captor.capture()); + assertEquals(AppRole.CLIENT, captor.getValue()); + } + + @Test + @DisplayName("The credentials are forwarded to the provider exactly as received") + void forwardsCredentials() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenReturn(UUID.randomUUID()); + + service.provisionClientUser("ana@example.com", VALID_PASSWORD); + + verify(identityProvider).createUser("ana@example.com", VALID_PASSWORD, AppRole.CLIENT); + } + } + + // ----------------------------------------------------------------- // + // Decision table: the complete mapUpstream switch (8 of 8 arms) // + // ----------------------------------------------------------------- // + + @Nested + @DisplayName("Upstream error translation (every arm of mapUpstream)") + class UpstreamMapping { + + @ParameterizedTest(name = "{0} is reported to the caller as {1}") + @CsvSource({ + "USER_ALREADY_EXISTS, DUPLICATE_EMAIL", + "RATE_LIMITED, RATE_LIMITED", + "USER_NOT_FOUND, RESOURCE_NOT_FOUND", + "TOKEN_INVALID, VERIFICATION_TOKEN_INVALID", + "TOKEN_EXPIRED, VERIFICATION_TOKEN_INVALID", + "INVALID_CREDENTIALS, INVALID_CREDENTIALS", + "EMAIL_NOT_CONFIRMED, EMAIL_NOT_CONFIRMED", + "UNAVAILABLE, UPSTREAM_AUTH_ERROR" + }) + @DisplayName("Each upstream error maps to its business error code during provisioning") + void mapsEachUpstreamError(UpstreamAuthError error, ErrorCode expected) { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + + assertEquals(expected, ex.errorCode()); + } + + @Test + @DisplayName("An unavailable provider keeps the upstream message for diagnosis") + void unavailableKeepsUpstreamMessage() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenThrow(upstream(UpstreamAuthError.UNAVAILABLE)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + + assertEquals("gotrue said UNAVAILABLE", ex.getMessage()); + } + + @Test + @DisplayName("A duplicate email is reported with the DUPLICATE_EMAIL default message") + void duplicateEmailUsesDefaultMessage() { + when(identityProvider.createUser(anyString(), anyString(), any(AppRole.class))) + .thenThrow(upstream(UpstreamAuthError.USER_ALREADY_EXISTS)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", VALID_PASSWORD)); + + assertEquals(ErrorCode.DUPLICATE_EMAIL.defaultMessage(), ex.getMessage()); + } + } + + // ================================================================= // + // deprovisionUser: compensation, must never fail // + // ================================================================= // + + @Nested + @DisplayName("Deprovisioning (compensating action, never propagates)") + class Deprovisioning { + + @Test + @DisplayName("Deprovisioning deletes the auth user at the identity provider") + void deletesUserAtProvider() { + UUID id = UUID.randomUUID(); + doNothing().when(identityProvider).deleteUser(any(UUID.class)); + + service.deprovisionUser(id); + + verify(identityProvider).deleteUser(id); + } + + @ParameterizedTest(name = "{0} while compensating is swallowed") + @EnumSource(UpstreamAuthError.class) + @DisplayName("An upstream failure while compensating is swallowed instead of propagated") + void upstreamFailureIsSwallowed(UpstreamAuthError error) { + doThrow(upstream(error)).when(identityProvider).deleteUser(any(UUID.class)); + + assertDoesNotThrow(() -> service.deprovisionUser(UUID.randomUUID())); + } + + @Test + @DisplayName("Any runtime failure while compensating is swallowed instead of propagated") + void runtimeFailureIsSwallowed() { + doThrow(new IllegalStateException("connection reset")) + .when(identityProvider).deleteUser(any(UUID.class)); + + assertDoesNotThrow(() -> service.deprovisionUser(UUID.randomUUID())); + } + } + + // ================================================================= // + // resendSignupVerification: anti-enumeration // + // ================================================================= // + + @Nested + @DisplayName("Resending the signup verification") + class ResendSignupVerification { + + @Test + @DisplayName("The resend request is delegated to the identity provider") + void delegatesToProvider() { + doNothing().when(identityProvider).resendSignupVerification(anyString()); + + service.resendSignupVerification("ana@example.com"); + + verify(identityProvider).resendSignupVerification("ana@example.com"); + } + + @Test + @DisplayName("A resend for an unknown email succeeds so accounts cannot be enumerated") + void unknownEmailIsAnsweredAsSuccess() { + doThrow(upstream(UpstreamAuthError.USER_NOT_FOUND)) + .when(identityProvider).resendSignupVerification(anyString()); + + assertDoesNotThrow(() -> service.resendSignupVerification("ghost@example.com")); + } + + @ParameterizedTest(name = "{0} is escalated as a business error") + @EnumSource(value = UpstreamAuthError.class, names = "USER_NOT_FOUND", mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any upstream failure other than an unknown user is escalated") + void otherUpstreamErrorsAreEscalated(UpstreamAuthError error) { + doThrow(upstream(error)).when(identityProvider).resendSignupVerification(anyString()); + + assertThrows(BusinessException.class, () -> service.resendSignupVerification("ana@example.com")); + } + + @Test + @DisplayName("A rate-limited resend is escalated as RATE_LIMITED") + void rateLimitedResendIsMapped() { + doThrow(upstream(UpstreamAuthError.RATE_LIMITED)) + .when(identityProvider).resendSignupVerification(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.resendSignupVerification("ana@example.com")); + + assertEquals(ErrorCode.RATE_LIMITED, ex.errorCode()); + } + } + + // ================================================================= // + // confirmEmail // + // ================================================================= // + + @Nested + @DisplayName("Confirming the email with a one-time token") + class ConfirmEmail { + + @Test + @DisplayName("A valid token returns the confirmed user reported by the provider") + void validTokenReturnsConfirmedUser() { + ConfirmedUser confirmed = new ConfirmedUser(UUID.randomUUID(), "ana@example.com"); + when(identityProvider.verifyEmailToken(anyString())).thenReturn(confirmed); + + assertSame(confirmed, service.confirmEmail("token-hash")); + } + + @Test + @DisplayName("The token hash is forwarded to the provider exactly as received") + void forwardsTokenHash() { + when(identityProvider.verifyEmailToken(anyString())) + .thenReturn(new ConfirmedUser(UUID.randomUUID(), "ana@example.com")); + + service.confirmEmail("token-hash"); + + verify(identityProvider).verifyEmailToken("token-hash"); + } + + @ParameterizedTest(name = "{0} is reported as VERIFICATION_TOKEN_INVALID") + @EnumSource(value = UpstreamAuthError.class, names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}) + @DisplayName("An invalid or expired confirmation token is reported as an invalid token") + void badTokenIsTranslated(UpstreamAuthError error) { + when(identityProvider.verifyEmailToken(anyString())).thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertEquals(ErrorCode.VERIFICATION_TOKEN_INVALID, ex.errorCode()); + } + + @Test + @DisplayName("An invalid confirmation token does not leak the provider message") + void badTokenUsesDefaultMessage() { + when(identityProvider.verifyEmailToken(anyString())) + .thenThrow(upstream(UpstreamAuthError.TOKEN_EXPIRED)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertEquals(ErrorCode.VERIFICATION_TOKEN_INVALID.defaultMessage(), ex.getMessage()); + } + + @ParameterizedTest(name = "{0} is escalated as a business error") + @EnumSource(value = UpstreamAuthError.class, + names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any other upstream failure during confirmation is escalated") + void otherUpstreamErrorsAreEscalated(UpstreamAuthError error) { + when(identityProvider.verifyEmailToken(anyString())).thenThrow(upstream(error)); + + assertThrows(BusinessException.class, () -> service.confirmEmail("token-hash")); + } + + @Test + @DisplayName("Confirming an email for an unknown user is reported as RESOURCE_NOT_FOUND") + void unknownUserIsMapped() { + when(identityProvider.verifyEmailToken(anyString())) + .thenThrow(upstream(UpstreamAuthError.USER_NOT_FOUND)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertEquals(ErrorCode.RESOURCE_NOT_FOUND, ex.errorCode()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/ConfirmEmailUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/ConfirmEmailUseCaseTest.java new file mode 100644 index 0000000..651a2e5 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/ConfirmEmailUseCaseTest.java @@ -0,0 +1,202 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser; +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * HU-001 email confirmation use case. + * + * Techniques applied: + * - Decision table over the two inputs that decide the outcome: token accepted + * by the provider (yes/no) and client profile present in our database + * (yes/no), crossed with the status the profile is in. + * - White-box branch coverage: the orElseThrow branch, the idempotent branch of + * Client.verifyEmail and the branch where the aggregate rejects the change. + * - Interaction testing: the profile must be persisted after the status change, + * and nothing must be persisted when a rule fails. + */ +class ConfirmEmailUseCaseTest { + + private static final UUID CLIENT_ID = UUID.fromString("44444444-4444-4444-4444-444444444444"); + private static final String TOKEN = "token-hash"; + + private ClientRepository clientRepository; + private UserProvisioning userProvisioning; + private ConfirmEmailUseCase useCase; + + @BeforeEach + void setUp() { + clientRepository = mock(ClientRepository.class); + userProvisioning = mock(UserProvisioning.class); + useCase = new ConfirmEmailUseCase(clientRepository, userProvisioning); + } + + private Client clientInStatus(ClientStatus status) { + return new Client(CLIENT_ID, "Ana Perez", "CC12345678", LocalDate.of(1995, 4, 10), + "ana@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, status); + } + + private void tokenResolvesTo(Client client) { + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)).thenReturn(Optional.of(client)); + when(clientRepository.save(any(Client.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + // --- Happy path ---------------------------------------------------------- + + @Test + @DisplayName("A valid token activates the pending client and reports the new status") + void validTokenActivatesPendingClient() { + Client pending = clientInStatus(ClientStatus.PENDING_VERIFICATION); + tokenResolvesTo(pending); + + RegistrationOutcome outcome = useCase.confirm(TOKEN); + + assertEquals(CLIENT_ID, outcome.clientId()); + assertEquals("ana@example.com", outcome.email()); + assertEquals(ClientStatus.ACTIVE, outcome.status()); + assertTrue(pending.canConfirmBooking()); + } + + @Test + @DisplayName("The activated client is persisted after the status change, not before") + void activatedClientIsPersisted() { + Client pending = clientInStatus(ClientStatus.PENDING_VERIFICATION); + tokenResolvesTo(pending); + + useCase.confirm(TOKEN); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Client.class); + verify(clientRepository).save(captor.capture()); + assertSame(pending, captor.getValue()); + assertEquals(ClientStatus.ACTIVE, captor.getValue().getStatus()); + + InOrder order = inOrder(userProvisioning, clientRepository); + order.verify(userProvisioning).confirmEmail(TOKEN); + order.verify(clientRepository).findById(CLIENT_ID); + order.verify(clientRepository).save(pending); + } + + @Test + @DisplayName("The client is looked up by the provider user id, which is also the client id") + void clientIsLookedUpByProviderUserId() { + tokenResolvesTo(clientInStatus(ClientStatus.PENDING_VERIFICATION)); + + useCase.confirm(TOKEN); + + verify(clientRepository).findById(CLIENT_ID); + } + + // --- Idempotency --------------------------------------------------------- + + @Test + @DisplayName("Confirming twice is idempotent: the second call succeeds and keeps the client ACTIVE") + void confirmingTwiceIsIdempotent() { + Client pending = clientInStatus(ClientStatus.PENDING_VERIFICATION); + tokenResolvesTo(pending); + + RegistrationOutcome first = useCase.confirm(TOKEN); + RegistrationOutcome second = useCase.confirm(TOKEN); + + assertEquals(ClientStatus.ACTIVE, first.status()); + assertEquals(ClientStatus.ACTIVE, second.status()); + assertEquals(first, second); + verify(clientRepository, times(2)).save(pending); + } + + @Test + @DisplayName("Confirming an already ACTIVE client neither fails nor changes the status") + void confirmingAnAlreadyActiveClientDoesNotFail() { + Client active = clientInStatus(ClientStatus.ACTIVE); + tokenResolvesTo(active); + + RegistrationOutcome outcome = useCase.confirm(TOKEN); + + assertEquals(ClientStatus.ACTIVE, outcome.status()); + verify(clientRepository).save(active); + } + + // --- Exceptional paths --------------------------------------------------- + + @Test + @DisplayName("A token accepted by the provider without a client profile raises RESOURCE_NOT_FOUND") + void missingClientProfileRaisesResourceNotFound() { + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.confirm(TOKEN)); + + assertEquals(ErrorCode.RESOURCE_NOT_FOUND, ex.errorCode()); + assertEquals("Client profile not found for the confirmed user", ex.getMessage()); + verify(clientRepository, never()).save(any(Client.class)); + } + + @Test + @DisplayName("An invalid or already used token fails at the provider and never touches the client profile") + void invalidTokenIsPropagatedWithoutTouchingTheRepository() { + BusinessException invalid = new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID); + when(userProvisioning.confirmEmail(anyString())).thenThrow(invalid); + + BusinessException thrown = assertThrows(BusinessException.class, () -> useCase.confirm("used-token")); + + assertSame(invalid, thrown); + verifyNoInteractions(clientRepository); + } + + @Test + @DisplayName("A suspended client cannot be reactivated through email confirmation and is not persisted") + void suspendedClientCannotBeConfirmed() { + Client suspended = clientInStatus(ClientStatus.SUSPENDED); + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)).thenReturn(Optional.of(suspended)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.confirm(TOKEN)); + + assertEquals(ErrorCode.VALIDATION_ERROR, ex.errorCode()); + assertEquals(ClientStatus.SUSPENDED, suspended.getStatus()); + assertFalse(suspended.canConfirmBooking()); + verify(clientRepository, never()).save(any(Client.class)); + } + + @Test + @DisplayName("A failure while persisting the activation is propagated to the caller") + void persistenceFailureIsPropagated() { + when(userProvisioning.confirmEmail(TOKEN)).thenReturn(new ConfirmedUser(CLIENT_ID, "ana@example.com")); + when(clientRepository.findById(CLIENT_ID)) + .thenReturn(Optional.of(clientInStatus(ClientStatus.PENDING_VERIFICATION))); + RuntimeException boom = new IllegalStateException("database down"); + when(clientRepository.save(any(Client.class))).thenThrow(boom); + + assertSame(boom, assertThrows(RuntimeException.class, () -> useCase.confirm(TOKEN))); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java new file mode 100644 index 0000000..bd519b6 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java @@ -0,0 +1,80 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Input carrier of the registration use case. + * + * Techniques applied: contract testing of the record (accessors, value + * equality, hashCode consistency) plus a check that the raw password is not + * part of the accidental logging surface we rely on. + */ +class RegisterClientCommandTest { + + private static final LocalDate BIRTH_DATE = LocalDate.of(1995, 4, 10); + + private RegisterClientCommand command() { + return new RegisterClientCommand("Ana Perez", "CC12345678", BIRTH_DATE, + "ana@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"); + } + + @Test + @DisplayName("The command exposes every registration field exactly as received") + void exposesEveryField() { + RegisterClientCommand command = command(); + + assertEquals("Ana Perez", command.fullName()); + assertEquals("CC12345678", command.document()); + assertEquals(BIRTH_DATE, command.birthDate()); + assertEquals("ana@example.com", command.email()); + assertEquals("+573001234567", command.phone()); + assertEquals("Bogota", command.city()); + assertEquals(NotificationChannel.EMAIL, command.notificationChannel()); + assertEquals("Str0ng!Pass", command.password()); + } + + @Test + @DisplayName("Two commands with the same registration data are equal and share the hash") + void valueEquality() { + assertEquals(command(), command()); + assertEquals(command().hashCode(), command().hashCode()); + } + + @Test + @DisplayName("A command that differs in a single field is not equal") + void differentEmailBreaksEquality() { + RegisterClientCommand other = new RegisterClientCommand("Ana Perez", "CC12345678", BIRTH_DATE, + "otra@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"); + + assertNotEquals(command(), other); + } + + @Test + @DisplayName("The command tolerates optional fields left null so the use case can validate them") + void toleratesNullOptionalFields() { + RegisterClientCommand command = new RegisterClientCommand(null, null, null, null, null, null, null, null); + + assertNull(command.fullName()); + assertNull(command.birthDate()); + assertNull(command.notificationChannel()); + assertEquals(command, new RegisterClientCommand(null, null, null, null, null, null, null, null)); + } + + @Test + @DisplayName("The command rendering keeps the field names used when debugging a registration") + void renderingKeepsFieldNames() { + String rendered = command().toString(); + + assertTrue(rendered.contains("fullName=Ana Perez"), rendered); + assertTrue(rendered.contains("email=ana@example.com"), rendered); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientUseCaseTest.java new file mode 100644 index 0000000..252238d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientUseCaseTest.java @@ -0,0 +1,388 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * HU-001 registration use case. + * + * Techniques applied: + * - Decision table over the validation rules (age, duplicate email, duplicate + * document) including the precedence between them. + * - Boundary value analysis on the age rule, driven by a fixed Clock. + * - Black-box partitioning of the normalisation rules (case and surrounding + * blanks) verified with ArgumentCaptor on the persisted aggregate. + * - White-box branch coverage: the happy path, the three guard clauses and + * both entries into the catch block that compensates the provisioning. + * - Interaction testing: what must be called, in which order, and what must + * never be called when a rule fails. + */ +class RegisterClientUseCaseTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 9, 18); + private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC); + private static final UUID PROVISIONED_ID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + private ClientRepository clientRepository; + private UserProvisioning userProvisioning; + private RegisterClientUseCase useCase; + + @BeforeEach + void setUp() { + clientRepository = mock(ClientRepository.class); + userProvisioning = mock(UserProvisioning.class); + useCase = new RegisterClientUseCase(clientRepository, userProvisioning, CLOCK); + } + + private RegisterClientCommand command() { + return command("ana@example.com", "cc12345678", LocalDate.of(1995, 4, 10)); + } + + private RegisterClientCommand command(String email, String document, LocalDate birthDate) { + return new RegisterClientCommand("Ana Perez", document, birthDate, email, + "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"); + } + + private void provisioningSucceeds() { + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(PROVISIONED_ID); + when(clientRepository.save(any(Client.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + private Client capturedSavedClient() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Client.class); + verify(clientRepository).save(captor.capture()); + return captor.getValue(); + } + + // --- Happy path ---------------------------------------------------------- + + @Test + @DisplayName("A valid adult registration provisions the credential, stores the profile and triggers the verification email") + void happyPathRegistersTheClient() { + provisioningSucceeds(); + + RegistrationOutcome outcome = useCase.register(command()); + + assertEquals(PROVISIONED_ID, outcome.clientId()); + assertEquals("ana@example.com", outcome.email()); + assertEquals(ClientStatus.PENDING_VERIFICATION, outcome.status()); + + InOrder order = inOrder(clientRepository, userProvisioning); + order.verify(clientRepository).existsByEmail("ana@example.com"); + order.verify(clientRepository).existsByDocument("CC12345678"); + order.verify(userProvisioning).provisionClientUser("ana@example.com", "Str0ng!Pass"); + order.verify(clientRepository).save(any(Client.class)); + order.verify(userProvisioning).resendSignupVerification("ana@example.com"); + order.verifyNoMoreInteractions(); + } + + @Test + @DisplayName("The stored client starts in PENDING_VERIFICATION and never in ACTIVE") + void storedClientStartsPendingVerification() { + provisioningSucceeds(); + + useCase.register(command()); + + Client saved = capturedSavedClient(); + assertEquals(ClientStatus.PENDING_VERIFICATION, saved.getStatus()); + assertEquals(PROVISIONED_ID, saved.getId()); + } + + @Test + @DisplayName("The client is never deprovisioned when the registration completes") + void noCompensationOnSuccess() { + provisioningSucceeds(); + + useCase.register(command()); + + verify(userProvisioning, never()).deprovisionUser(any(UUID.class)); + } + + // --- Normalisation ------------------------------------------------------- + + @Test + @DisplayName("Email is lowercased, document uppercased and name, phone and city are trimmed before persisting") + void normalisesEveryFieldBeforePersisting() { + provisioningSucceeds(); + RegisterClientCommand raw = new RegisterClientCommand(" Ana Perez ", " cc12345678 ", + LocalDate.of(1995, 4, 10), "Ana.Perez@EXAMPLE.COM", " +573001234567 ", " Bogota ", + NotificationChannel.SMS, "Str0ng!Pass"); + + useCase.register(raw); + + Client saved = capturedSavedClient(); + assertEquals("Ana Perez", saved.getFullName()); + assertEquals("CC12345678", saved.getDocument()); + assertEquals("ana.perez@example.com", saved.getEmail()); + assertEquals("+573001234567", saved.getPhone()); + assertEquals("Bogota", saved.getCity()); + assertEquals(NotificationChannel.SMS, saved.getNotificationChannel()); + assertEquals(LocalDate.of(1995, 4, 10), saved.getBirthDate()); + } + + @Test + @DisplayName("The identity provider receives the normalised email, not the one typed by the user") + void providerReceivesNormalisedEmail() { + provisioningSucceeds(); + + useCase.register(command("Ana.Perez@EXAMPLE.COM", "cc12345678", LocalDate.of(1995, 4, 10))); + + verify(userProvisioning).provisionClientUser("ana.perez@example.com", "Str0ng!Pass"); + verify(userProvisioning).resendSignupVerification("ana.perez@example.com"); + } + + @Test + @DisplayName("The duplicate document check runs against the normalised document, not the typed one") + void documentUniquenessIsCheckedNormalised() { + provisioningSucceeds(); + + useCase.register(command("ana@example.com", " cc-123 ab ", LocalDate.of(1995, 4, 10))); + + verify(clientRepository).existsByDocument("CC-123 AB"); + } + + @Test + @DisplayName("The duplicate email check runs against the lowercased email") + void emailUniquenessIsCheckedNormalised() { + provisioningSucceeds(); + + useCase.register(command("ANA@EXAMPLE.COM", "cc12345678", LocalDate.of(1995, 4, 10))); + + verify(clientRepository).existsByEmail("ana@example.com"); + } + + @Test + @DisplayName("DEFECT PIN: surrounding blanks in the email are not trimmed, unlike every other field") + void emailIsLowercasedButNotTrimmed() { + // Documented gap, see the QA report: RegisterClientUseCase line 45 applies + // toLowerCase without trim(), so " Ana@Example.com " reaches the provider + // and the uniqueness check with its blanks. Pinned here so the fix is visible. + provisioningSucceeds(); + + useCase.register(command(" Ana@Example.com ", "cc12345678", LocalDate.of(1995, 4, 10))); + + verify(clientRepository).existsByEmail(" ana@example.com "); + assertEquals(" ana@example.com ", capturedSavedClient().getEmail()); + } + + // --- Age rule ------------------------------------------------------------ + + @Test + @DisplayName("A minor is rejected with MINOR_NOT_ALLOWED before any repository or provider call") + void minorIsRejected() { + RegisterClientCommand minor = command("ana@example.com", "cc12345678", TODAY.minusYears(18).plusDays(1)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(minor)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, ex.errorCode()); + verifyNoInteractions(clientRepository); + verifyNoInteractions(userProvisioning); + } + + @ParameterizedTest(name = "born {0} -> accepted? {1}") + @DisplayName("The age boundary is evaluated against the fixed clock: the 18th birthday is already valid") + @CsvSource({ + "2008-09-19, false", + "2008-09-18, true", + "2008-09-17, true" + }) + void ageBoundaryAgainstFixedClock(LocalDate birthDate, boolean accepted) { + provisioningSucceeds(); + RegisterClientCommand cmd = command("ana@example.com", "cc12345678", birthDate); + + if (accepted) { + assertEquals(ClientStatus.PENDING_VERIFICATION, useCase.register(cmd).status()); + } else { + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, + assertThrows(BusinessException.class, () -> useCase.register(cmd)).errorCode()); + } + } + + @Test + @DisplayName("Age is measured with the injected clock, so a client who is a minor today is not registered") + void ageUsesTheInjectedClock() { + Clock fiveYearsEarlier = Clock.fixed( + LocalDate.of(2021, 9, 18).atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC); + RegisterClientUseCase pastUseCase = + new RegisterClientUseCase(clientRepository, userProvisioning, fiveYearsEarlier); + RegisterClientCommand justEighteenToday = command("ana@example.com", "cc12345678", TODAY.minusYears(18)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, + assertThrows(BusinessException.class, () -> pastUseCase.register(justEighteenToday)).errorCode()); + } + + // --- Uniqueness rules and their precedence ------------------------------- + + @Test + @DisplayName("A duplicate email is rejected with DUPLICATE_EMAIL before the document is even checked") + void duplicateEmailIsRejected() { + when(clientRepository.existsByEmail("ana@example.com")).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertEquals(ErrorCode.DUPLICATE_EMAIL, ex.errorCode()); + verify(clientRepository).existsByEmail("ana@example.com"); + verifyNoMoreInteractions(clientRepository); + verifyNoInteractions(userProvisioning); + } + + @Test + @DisplayName("A duplicate document is rejected with DUPLICATE_DOCUMENT and no credential is provisioned") + void duplicateDocumentIsRejected() { + when(clientRepository.existsByEmail("ana@example.com")).thenReturn(false); + when(clientRepository.existsByDocument("CC12345678")).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertEquals(ErrorCode.DUPLICATE_DOCUMENT, ex.errorCode()); + verify(clientRepository, never()).save(any(Client.class)); + verifyNoInteractions(userProvisioning); + } + + @Test + @DisplayName("A minor with an email already registered fails for being a minor, not for the duplicate") + void ageRuleWinsOverDuplicateEmail() { + when(clientRepository.existsByEmail(anyString())).thenReturn(true); + RegisterClientCommand minorWithTakenEmail = + command("ana@example.com", "cc12345678", TODAY.minusYears(10)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(minorWithTakenEmail)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, ex.errorCode()); + verifyNoInteractions(clientRepository); + } + + @Test + @DisplayName("A minor with a document already registered fails for being a minor, not for the duplicate") + void ageRuleWinsOverDuplicateDocument() { + when(clientRepository.existsByDocument(anyString())).thenReturn(true); + RegisterClientCommand minorWithTakenDocument = + command("ana@example.com", "cc12345678", TODAY.minusYears(3)); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(minorWithTakenDocument)); + + assertEquals(ErrorCode.MINOR_NOT_ALLOWED, ex.errorCode()); + verifyNoInteractions(clientRepository); + } + + @Test + @DisplayName("A duplicate email wins over a duplicate document when both collide") + void duplicateEmailWinsOverDuplicateDocument() { + when(clientRepository.existsByEmail(anyString())).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertEquals(ErrorCode.DUPLICATE_EMAIL, ex.errorCode()); + verify(clientRepository, never()).existsByDocument(anyString()); + } + + // --- Compensation of the provisioned credential -------------------------- + + @Nested + @DisplayName("Compensation when the registration fails after provisioning") + class Compensation { + + @Test + @DisplayName("If storing the profile fails the provisioned credential is deleted and the original failure is rethrown") + void saveFailureCompensatesProvisioning() { + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(PROVISIONED_ID); + RuntimeException boom = new IllegalStateException("database down"); + when(clientRepository.save(any(Client.class))).thenThrow(boom); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> useCase.register(command())); + + assertSame(boom, thrown); + verify(userProvisioning).deprovisionUser(PROVISIONED_ID); + verify(userProvisioning, never()).resendSignupVerification(anyString()); + } + + @Test + @DisplayName("If the verification email cannot be sent the provisioned credential is deleted and the original failure is rethrown") + void resendFailureCompensatesProvisioning() { + provisioningSucceeds(); + BusinessException upstream = new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR); + doThrow(upstream).when(userProvisioning).resendSignupVerification(anyString()); + + BusinessException thrown = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertSame(upstream, thrown); + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, thrown.errorCode()); + verify(userProvisioning).deprovisionUser(PROVISIONED_ID); + } + + @Test + @DisplayName("The compensation targets exactly the id that was provisioned") + void compensationUsesTheProvisionedId() { + UUID otherId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(otherId); + when(clientRepository.save(any(Client.class))).thenThrow(new IllegalStateException("boom")); + + assertThrows(RuntimeException.class, () -> useCase.register(command())); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UUID.class); + verify(userProvisioning).deprovisionUser(captor.capture()); + assertEquals(otherId, captor.getValue()); + } + + @Test + @DisplayName("If provisioning itself fails nothing is stored and there is nothing to compensate") + void provisioningFailureNeedsNoCompensation() { + BusinessException upstream = new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR); + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenThrow(upstream); + + BusinessException thrown = assertThrows(BusinessException.class, () -> useCase.register(command())); + + assertSame(upstream, thrown); + verify(userProvisioning, never()).deprovisionUser(any(UUID.class)); + verify(clientRepository, never()).save(any(Client.class)); + } + + @Test + @DisplayName("DEFECT PIN: if the compensation also fails, its error replaces the original cause") + void compensationFailureMasksTheOriginalCause() { + // Documented gap, see the QA report: RegisterClientUseCase line 73 calls + // deprovisionUser outside any guard, so a failing compensation hides the + // real reason the registration failed. + when(userProvisioning.provisionClientUser(anyString(), anyString())).thenReturn(PROVISIONED_ID); + when(clientRepository.save(any(Client.class))).thenThrow(new IllegalStateException("database down")); + doThrow(new IllegalStateException("provider down")).when(userProvisioning).deprovisionUser(PROVISIONED_ID); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> useCase.register(command())); + + assertEquals("provider down", thrown.getMessage()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java new file mode 100644 index 0000000..8bd25e7 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java @@ -0,0 +1,72 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Output carrier shared by registration and email confirmation. + * + * Techniques applied: contract testing of the record (accessors, value + * equality, hashCode consistency) and equivalence partitioning over the + * statuses the use cases can return. + */ +class RegistrationOutcomeTest { + + private static final UUID CLIENT_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + + @Test + @DisplayName("The outcome exposes the client id, email and status returned to the API") + void exposesEveryField() { + RegistrationOutcome outcome = + new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.PENDING_VERIFICATION); + + assertEquals(CLIENT_ID, outcome.clientId()); + assertEquals("ana@example.com", outcome.email()); + assertEquals(ClientStatus.PENDING_VERIFICATION, outcome.status()); + } + + @Test + @DisplayName("Two outcomes describing the same registration are equal and share the hash") + void valueEquality() { + RegistrationOutcome one = new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE); + RegistrationOutcome another = new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE); + + assertEquals(one, another); + assertEquals(one.hashCode(), another.hashCode()); + } + + @Test + @DisplayName("Registration and confirmation outcomes of the same client differ by status") + void differentStatusBreaksEquality() { + RegistrationOutcome registered = + new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.PENDING_VERIFICATION); + RegistrationOutcome confirmed = + new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE); + + assertNotEquals(registered, confirmed); + } + + @ParameterizedTest + @DisplayName("Any client status can be reported back without losing information") + @EnumSource(ClientStatus.class) + void carriesAnyStatus(ClientStatus status) { + assertEquals(status, new RegistrationOutcome(CLIENT_ID, "ana@example.com", status).status()); + } + + @Test + @DisplayName("The outcome rendering keeps the client id used when tracing a registration") + void renderingKeepsClientId() { + String rendered = new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE).toString(); + + assertTrue(rendered.contains(CLIENT_ID.toString()), rendered); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/ResendVerificationUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/ResendVerificationUseCaseTest.java new file mode 100644 index 0000000..f864537 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/application/ResendVerificationUseCaseTest.java @@ -0,0 +1,146 @@ +package com.codefactory.bookingplatform.identity.application; + +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * HU-001 verification email resend. + * + * Techniques applied: + * - Decision table with a single condition: does the normalised email belong to + * a known client? Both outcomes are covered, including the anti-enumeration + * rule that an unknown email must look exactly like a known one to the caller. + * - Black-box partitioning of the normalisation rule (upper, mixed and lower + * case addresses). + * - White-box branch coverage: both arms of ifPresentOrElse. + * - Interaction testing: the provider must not be called for unknown emails. + */ +class ResendVerificationUseCaseTest { + + private ClientRepository clientRepository; + private UserProvisioning userProvisioning; + private ResendVerificationUseCase useCase; + + @BeforeEach + void setUp() { + clientRepository = mock(ClientRepository.class); + userProvisioning = mock(UserProvisioning.class); + useCase = new ResendVerificationUseCase(clientRepository, userProvisioning); + } + + private Client clientWithEmail(String email, ClientStatus status) { + return new Client(UUID.randomUUID(), "Ana Perez", "CC12345678", LocalDate.of(1995, 4, 10), + email, "+573001234567", "Bogota", NotificationChannel.EMAIL, status); + } + + // --- Known email --------------------------------------------------------- + + @Test + @DisplayName("A known email gets a new verification message from the identity provider") + void knownEmailTriggersTheProvider() { + when(clientRepository.findByEmail("ana@example.com")) + .thenReturn(Optional.of(clientWithEmail("ana@example.com", ClientStatus.PENDING_VERIFICATION))); + + useCase.resend("ana@example.com"); + + verify(userProvisioning).resendSignupVerification("ana@example.com"); + } + + @ParameterizedTest(name = "\"{0}\" is looked up and resent as \"{1}\"") + @DisplayName("The email is lowercased before the lookup and before the provider call") + @CsvSource({ + "ANA@EXAMPLE.COM, ana@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com", + "ana@example.com, ana@example.com" + }) + void emailIsNormalisedBeforeLookupAndResend(String typedEmail, String normalisedEmail) { + when(clientRepository.findByEmail(normalisedEmail)) + .thenReturn(Optional.of(clientWithEmail(normalisedEmail, ClientStatus.PENDING_VERIFICATION))); + + useCase.resend(typedEmail); + + verify(clientRepository).findByEmail(normalisedEmail); + verify(userProvisioning).resendSignupVerification(normalisedEmail); + } + + @ParameterizedTest + @DisplayName("The resend is offered whatever the client status is, because the provider owns that decision") + @EnumSource(ClientStatus.class) + void resendIsOfferedForEveryStatus(ClientStatus status) { + when(clientRepository.findByEmail("ana@example.com")) + .thenReturn(Optional.of(clientWithEmail("ana@example.com", status))); + + useCase.resend("ana@example.com"); + + verify(userProvisioning).resendSignupVerification("ana@example.com"); + } + + // --- Unknown email: anti-enumeration ------------------------------------- + + @Test + @DisplayName("An unknown email is silently accepted so the endpoint cannot be used to enumerate users") + void unknownEmailIsSilentlyAccepted() { + when(clientRepository.findByEmail("desconocido@example.com")).thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> useCase.resend("desconocido@example.com")); + } + + @Test + @DisplayName("An unknown email never reaches the identity provider") + void unknownEmailDoesNotReachTheProvider() { + when(clientRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + + useCase.resend("DESCONOCIDO@example.com"); + + verify(clientRepository).findByEmail("desconocido@example.com"); + verifyNoInteractions(userProvisioning); + } + + // --- Exceptional paths --------------------------------------------------- + + @Test + @DisplayName("A provider failure while resending is propagated instead of being swallowed") + void providerFailureIsPropagated() { + when(clientRepository.findByEmail("ana@example.com")) + .thenReturn(Optional.of(clientWithEmail("ana@example.com", ClientStatus.PENDING_VERIFICATION))); + BusinessException upstream = new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR); + doThrow(upstream).when(userProvisioning).resendSignupVerification("ana@example.com"); + + assertSame(upstream, assertThrows(BusinessException.class, () -> useCase.resend("ana@example.com"))); + } + + @Test + @DisplayName("DEFECT PIN: a null email breaks the resend with NullPointerException instead of a validation error") + void nullEmailFailsWithNullPointerException() { + // Documented gap, see the QA report: ResendVerificationUseCase line 29 calls + // toLowerCase on the raw argument with no null guard of its own. + assertThrows(NullPointerException.class, () -> useCase.resend(null)); + verifyNoInteractions(clientRepository); + verifyNoInteractions(userProvisioning); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java new file mode 100644 index 0000000..32eb51d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java @@ -0,0 +1,51 @@ +package com.codefactory.bookingplatform.identity.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The status vocabulary is persisted as text and travels in the API contract, + * so the set of constants and their exact spelling are part of the contract. + * + * Techniques applied: equivalence partitioning over the enum domain + * (valid names vs. unknown names) and contract pinning of the ordering. + */ +class ClientStatusTest { + + @Test + @DisplayName("The client lifecycle declares exactly three statuses in registration order") + void declaresExactlyThreeStatuses() { + assertEquals( + List.of(ClientStatus.PENDING_VERIFICATION, ClientStatus.ACTIVE, ClientStatus.SUSPENDED), + Arrays.asList(ClientStatus.values())); + } + + @ParameterizedTest + @DisplayName("Every status round-trips through its persisted name") + @EnumSource(ClientStatus.class) + void statusRoundTripsThroughItsName(ClientStatus status) { + assertEquals(status, ClientStatus.valueOf(status.name())); + } + + @ParameterizedTest + @DisplayName("An unknown or wrongly cased status name is rejected instead of silently mapped") + @ValueSource(strings = {"PENDING", "active", "DELETED", "", " ACTIVE"}) + void unknownStatusNameIsRejected(String name) { + assertThrows(IllegalArgumentException.class, () -> ClientStatus.valueOf(name)); + } + + @Test + @DisplayName("PENDING_VERIFICATION is the first status so it is the natural default for a new client") + void pendingVerificationIsTheFirstStatus() { + assertEquals(0, ClientStatus.PENDING_VERIFICATION.ordinal()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java index 75374db..90568db 100644 --- a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java +++ b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientTest.java @@ -3,16 +3,31 @@ import com.codefactory.bookingplatform.shared.error.BusinessException; import com.codefactory.bookingplatform.shared.error.ErrorCode; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import java.time.LocalDate; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * Client aggregate: state machine {PENDING_VERIFICATION, ACTIVE, SUSPENDED} + * over the transitions {verifyEmail, suspend, reactivate}. + * + * Techniques applied: + * - State transition testing (the full 3x3 table is exercised cell by cell). + * - Decision table for canConfirmBooking (one row per status). + * - Branch coverage: every guard inside verifyEmail/suspend/reactivate, both + * for the path that mutates the state and for the path that throws. + */ class ClientTest { private Client newPendingClient() { @@ -21,6 +36,12 @@ private Client newPendingClient() { "Bogota", NotificationChannel.EMAIL); } + private Client clientInStatus(ClientStatus status) { + return new Client(UUID.randomUUID(), "Ana Perez", "12345678", + LocalDate.of(1995, 4, 10), "ana@example.com", "+573001234567", + "Bogota", NotificationChannel.EMAIL, status); + } + @Test @DisplayName("New client starts as PENDING_VERIFICATION and cannot confirm bookings") void newClientIsPendingVerification() { @@ -71,4 +92,172 @@ void suspensionLifecycle() { assertEquals(ClientStatus.ACTIVE, pending.getStatus()); assertTrue(pending.canConfirmBooking()); } + + // --- Factory and projection --------------------------------------------- + + @Test + @DisplayName("The pendingVerification factory keeps every attribute it receives") + void factoryKeepsEveryAttribute() { + UUID id = UUID.randomUUID(); + LocalDate birthDate = LocalDate.of(1990, 1, 31); + Client client = Client.pendingVerification(id, "Ana Perez", "CC-98765", birthDate, + "ana@example.com", "+573001234567", "Medellin", NotificationChannel.WHATSAPP); + + assertEquals(id, client.getId()); + assertEquals("Ana Perez", client.getFullName()); + assertEquals("CC-98765", client.getDocument()); + assertEquals(birthDate, client.getBirthDate()); + assertEquals("ana@example.com", client.getEmail()); + assertEquals("+573001234567", client.getPhone()); + assertEquals("Medellin", client.getCity()); + assertEquals(NotificationChannel.WHATSAPP, client.getNotificationChannel()); + assertEquals(ClientStatus.PENDING_VERIFICATION, client.getStatus()); + } + + @Test + @DisplayName("The rehydration constructor accepts any persisted status without re-running the state machine") + void rehydrationConstructorAcceptsAnyStatus() { + Client suspended = clientInStatus(ClientStatus.SUSPENDED); + assertEquals(ClientStatus.SUSPENDED, suspended.getStatus()); + assertNotNull(suspended.getId()); + } + + // --- State transition table: 3 states x 3 transitions = 9 cells ---------- + + /** + * from | verifyEmail | suspend | reactivate + * ---------------------+----------------------+----------------------+------------------- + * PENDING_VERIFICATION | to ACTIVE | BusinessException | BusinessException + * ACTIVE | stays ACTIVE (no-op) | to SUSPENDED | BusinessException + * SUSPENDED | BusinessException | stays SUSPENDED | to ACTIVE + */ + @ParameterizedTest(name = "{0} + {1} -> {2}") + @DisplayName("State transition table: every allowed transition lands on its target status") + @CsvSource({ + "PENDING_VERIFICATION, verifyEmail, ACTIVE", + "ACTIVE, verifyEmail, ACTIVE", + "ACTIVE, suspend, SUSPENDED", + "SUSPENDED, suspend, SUSPENDED", + "SUSPENDED, reactivate, ACTIVE" + }) + void allowedTransitions(ClientStatus from, String transition, ClientStatus expected) { + Client client = clientInStatus(from); + + applyTransition(client, transition); + + assertEquals(expected, client.getStatus()); + } + + @ParameterizedTest(name = "{0} + {1} is rejected") + @DisplayName("State transition table: every forbidden transition raises VALIDATION_ERROR and leaves the status untouched") + @CsvSource({ + "PENDING_VERIFICATION, suspend", + "PENDING_VERIFICATION, reactivate", + "ACTIVE, reactivate", + "SUSPENDED, verifyEmail" + }) + void forbiddenTransitions(ClientStatus from, String transition) { + Client client = clientInStatus(from); + + BusinessException ex = assertThrows(BusinessException.class, () -> applyTransition(client, transition)); + + assertEquals(ErrorCode.VALIDATION_ERROR, ex.errorCode()); + assertNotNull(ex.getMessage()); + assertEquals(from, client.getStatus(), "a rejected transition must not mutate the aggregate"); + } + + private void applyTransition(Client client, String transition) { + switch (transition) { + case "verifyEmail" -> client.verifyEmail(); + case "suspend" -> client.suspend(); + case "reactivate" -> client.reactivate(); + default -> throw new IllegalArgumentException("Unknown transition " + transition); + } + } + + @Nested + @DisplayName("Messages of the rejected transitions") + class RejectionMessages { + + @Test + @DisplayName("Verifying a suspended client explains that the status blocks the activation") + void verifySuspendedMessage() { + Client client = clientInStatus(ClientStatus.SUSPENDED); + BusinessException ex = assertThrows(BusinessException.class, client::verifyEmail); + assertEquals("Client SUSPENDED cannot be moved to ACTIVE by email verification", ex.getMessage()); + } + + @Test + @DisplayName("Suspending a pending client explains that verification comes first") + void suspendPendingMessage() { + Client client = clientInStatus(ClientStatus.PENDING_VERIFICATION); + BusinessException ex = assertThrows(BusinessException.class, client::suspend); + assertEquals("A pending verification client cannot be suspended", ex.getMessage()); + } + + @ParameterizedTest(name = "reactivate on {0}") + @DisplayName("Reactivating a client that is not suspended explains that only suspended clients qualify") + @CsvSource({"PENDING_VERIFICATION", "ACTIVE"}) + void reactivateNonSuspendedMessage(ClientStatus from) { + Client client = clientInStatus(from); + BusinessException ex = assertThrows(BusinessException.class, client::reactivate); + assertEquals("Only suspended clients can be reactivated", ex.getMessage()); + } + } + + // --- Decision table for the booking invariant ---------------------------- + + @ParameterizedTest(name = "{0} can confirm bookings? {1}") + @DisplayName("Only an ACTIVE client may confirm bookings") + @CsvSource({ + "PENDING_VERIFICATION, false", + "ACTIVE, true", + "SUSPENDED, false" + }) + void canConfirmBookingDecisionTable(ClientStatus status, boolean expected) { + assertEquals(expected, clientInStatus(status).canConfirmBooking()); + } + + @ParameterizedTest + @DisplayName("Every declared status is covered by the booking decision table") + @EnumSource(ClientStatus.class) + void everyStatusAnswersTheBookingInvariant(ClientStatus status) { + Client client = clientInStatus(status); + assertEquals(status == ClientStatus.ACTIVE, client.canConfirmBooking()); + } + + // --- Longer paths through the machine ------------------------------------ + + @Test + @DisplayName("A client can be suspended and reactivated repeatedly without losing the booking invariant") + void suspendReactivateCycleIsRepeatable() { + Client client = newPendingClient(); + client.verifyEmail(); + + for (int cycle = 0; cycle < 3; cycle++) { + client.suspend(); + assertFalse(client.canConfirmBooking()); + client.reactivate(); + assertTrue(client.canConfirmBooking()); + } + assertEquals(ClientStatus.ACTIVE, client.getStatus()); + } + + @Test + @DisplayName("Suspending twice keeps the client suspended instead of failing") + void suspendTwiceIsIdempotent() { + Client client = clientInStatus(ClientStatus.ACTIVE); + client.suspend(); + client.suspend(); + assertEquals(ClientStatus.SUSPENDED, client.getStatus()); + } + + @Test + @DisplayName("A reactivated client can be verified again without error because verification is idempotent on ACTIVE") + void verifyAfterReactivationIsIdempotent() { + Client client = clientInStatus(ClientStatus.SUSPENDED); + client.reactivate(); + client.verifyEmail(); + assertEquals(ClientStatus.ACTIVE, client.getStatus()); + } } diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java new file mode 100644 index 0000000..7b9ee12 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java @@ -0,0 +1,45 @@ +package com.codefactory.bookingplatform.identity.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The notification channel chosen at registration is persisted as text and is + * part of the registration request contract. + * + * Techniques applied: equivalence partitioning over the enum domain + * (valid names vs. unknown names) and contract pinning of the constant set. + */ +class NotificationChannelTest { + + @Test + @DisplayName("Registration offers exactly three notification channels") + void declaresExactlyThreeChannels() { + assertEquals( + List.of(NotificationChannel.EMAIL, NotificationChannel.SMS, NotificationChannel.WHATSAPP), + Arrays.asList(NotificationChannel.values())); + } + + @ParameterizedTest + @DisplayName("Every channel round-trips through its persisted name") + @EnumSource(NotificationChannel.class) + void channelRoundTripsThroughItsName(NotificationChannel channel) { + assertEquals(channel, NotificationChannel.valueOf(channel.name())); + } + + @ParameterizedTest + @DisplayName("An unsupported or wrongly cased channel name is rejected instead of silently mapped") + @ValueSource(strings = {"PUSH", "email", "Whatsapp", "", "TELEGRAM"}) + void unsupportedChannelNameIsRejected(String name) { + assertThrows(IllegalArgumentException.class, () -> NotificationChannel.valueOf(name)); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java index a07a797..59c4b21 100644 --- a/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java +++ b/src/test/java/com/codefactory/bookingplatform/identity/domain/service/AgePolicyTest.java @@ -2,12 +2,25 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.time.LocalDate; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * HU-001: self-registration is forbidden for minors. + * + * Techniques applied: + * - Equivalence partitioning: {minor} vs {adult} vs {birth date in the future}. + * - Boundary value analysis around the 18th birthday: the day before, the very + * day, and the day after. + * - Special-date analysis: 29 February of a leap year, where the anniversary + * does not exist in common years. + */ class AgePolicyTest { private static final LocalDate TODAY = LocalDate.of(2026, 9, 18); @@ -29,4 +42,92 @@ void oneDayShortOfEighteenIsMinor() { void olderAdultIsAdult() { assertTrue(AgePolicy.isAdult(TODAY.minusYears(35), TODAY)); } + + // --- Boundary value analysis: the exact 18th birthday -------------------- + + @Test + @DisplayName("The day after turning 18 is accepted") + void oneDayAfterEighteenthBirthdayIsAdult() { + assertTrue(AgePolicy.isAdult(TODAY.minusYears(18).minusDays(1), TODAY)); + } + + @ParameterizedTest(name = "born {0} -> adult on 2026-09-18? {1}") + @DisplayName("The 18th birthday is the exact boundary: before it the client is a minor, from it on an adult") + @CsvSource({ + // one year before the boundary: clearly a minor + "2009-09-18, false", + // one day before the 18th birthday: still a minor + "2008-09-19, false", + // the 18th birthday itself: adult + "2008-09-18, true", + // the day after the 18th birthday: adult + "2008-09-17, true", + // one year past the boundary: clearly an adult + "2007-09-18, true" + }) + void eighteenthBirthdayIsTheBoundary(LocalDate birthDate, boolean expectedAdult) { + assertEquals(expectedAdult, AgePolicy.isAdult(birthDate, TODAY)); + } + + @ParameterizedTest(name = "age {0} -> adult? {1}") + @DisplayName("Equivalence partitions: ages below 18 are rejected and ages from 18 up are accepted") + @CsvSource({ + "0, false", + "1, false", + "17, false", + "18, true", + "19, true", + "80, true" + }) + void equivalencePartitionsByWholeYears(int age, boolean expectedAdult) { + assertEquals(expectedAdult, AgePolicy.isAdult(TODAY.minusYears(age), TODAY)); + } + + @Test + @DisplayName("A newborn registering the same day is a minor") + void bornTodayIsMinor() { + assertFalse(AgePolicy.isAdult(TODAY, TODAY)); + } + + @Test + @DisplayName("A birth date in the future is rejected instead of wrapping into a positive age") + void futureBirthDateIsNotAdult() { + assertFalse(AgePolicy.isAdult(TODAY.plusYears(1), TODAY)); + } + + // --- Leap year: 29 February --------------------------------------------- + + @Test + @DisplayName("Someone born on 29 February is still a minor on 28 February of the common year they turn 18") + void leapDayBornIsMinorOnFebruaryTwentyEighth() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertFalse(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2026, 2, 28))); + } + + @Test + @DisplayName("Someone born on 29 February becomes an adult on 1 March of the common year they turn 18") + void leapDayBornIsAdultOnMarchFirst() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertTrue(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2026, 3, 1))); + } + + @Test + @DisplayName("Someone born on 29 February is an adult on 29 February of a later leap year") + void leapDayBornIsAdultOnTheNextLeapAnniversary() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertTrue(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2028, 2, 29))); + } + + @Test + @DisplayName("Someone born on 29 February is a minor on 28 February of the leap year before turning 18") + void leapDayBornIsMinorTheDayBeforeTheLeapAnniversary() { + LocalDate leapBirthDate = LocalDate.of(2008, 2, 29); + assertFalse(AgePolicy.isAdult(leapBirthDate, LocalDate.of(2026, 2, 27))); + } + + @Test + @DisplayName("The minimum legal age published by the policy is 18") + void minimumAgeIsEighteen() { + assertEquals(18, AgePolicy.MINIMUM_AGE); + } } From c9beff8b2db162de61eed291a9f9db295caa4c28 Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:40:24 -0500 Subject: [PATCH 03/12] test: pin the GoTrue error contract with a decision table GoTrueClient translates the identity provider's answers into the domain's error vocabulary, and that translation had 39 of its 45 branches untested. It is the class where a silent mistake costs the most: every wrong mapping becomes a wrong HTTP status for the client. mapError is a decision table, so it is tested as one: 30 rows over (context, HTTP status, response body), plus 6 rows that exist only to pin the order in which the rules fire. Each of the eight endpoints gets its happy path, its mapped error and a network failure. The headers are asserted too, because the rule that the secret key only travels to /admin/** is a security rule, not a detail. The adapters are tested against a mocked repository: what matters there is the translation and the lowercasing of the email before it reaches the query, not the database, which the integration tests already cover. 112 new cases. GoTrueClient goes from 19% to 100% of instructions and from 13% to 100% of branches; the adapters and the mapper from 0% to 100%. Whole suite: 408 unit tests plus 21 integration, green. Coverage 76.1% -> 94.4% of instructions, 60.3% -> 86.5% of branches. --- .../LoginAttemptRepositoryAdapterTest.java | 126 ++++ .../supabase/GoTrueClientTest.java | 596 +++++++++++++++++- .../mapper/ClientMapperTest.java | 146 +++++ .../ClientRepositoryAdapterTest.java | 175 +++++ 4 files changed, 1039 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/infrastructure/persistence/LoginAttemptRepositoryAdapterTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/infrastructure/mapper/ClientMapperTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/infrastructure/persistence/ClientRepositoryAdapterTest.java diff --git a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/persistence/LoginAttemptRepositoryAdapterTest.java b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/persistence/LoginAttemptRepositoryAdapterTest.java new file mode 100644 index 0000000..b509510 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/persistence/LoginAttemptRepositoryAdapterTest.java @@ -0,0 +1,126 @@ +package com.codefactory.bookingplatform.auth.infrastructure.persistence; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Translation contract of the login attempt adapter. The JPA repository is mocked on purpose: + * what is under test is the normalisation and the domain translation, not the database + * (real persistence is already covered by the Testcontainers integration tests). + */ +class LoginAttemptRepositoryAdapterTest { + + private LoginAttemptJpaRepository jpaRepository; + private LoginAttemptRepositoryAdapter adapter; + + @BeforeEach + void setUp() { + jpaRepository = mock(LoginAttemptJpaRepository.class); + adapter = new LoginAttemptRepositoryAdapter(jpaRepository); + } + + @ParameterizedTest(name = "[{index}] \"{0}\" is stored as \"{1}\"") + @CsvSource({ + "ana.perez@example.com, ana.perez@example.com", + "ANA.PEREZ@EXAMPLE.COM, ana.perez@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com" + }) + @DisplayName("The email is normalised to lower case before it is stored, so lockout counts one identity") + void recordAttemptNormalisesTheEmail(String input, String expected) { + Instant attemptedAt = Instant.parse("2026-09-22T10:15:30Z"); + + adapter.recordAttempt(input, false, attemptedAt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoginAttemptEntity.class); + verify(jpaRepository).save(captor.capture()); + assertEquals(expected, captor.getValue().getEmail()); + assertEquals(attemptedAt, captor.getValue().getAttemptedAt()); + } + + @ParameterizedTest(name = "[{index}] success={0} is persisted as is") + @CsvSource({"true", "false"}) + @DisplayName("Both successful and failed attempts are recorded, the outcome is kept verbatim") + void recordAttemptKeepsTheOutcome(boolean success) { + adapter.recordAttempt("ana.perez@example.com", success, Instant.parse("2026-09-22T10:15:30Z")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoginAttemptEntity.class); + verify(jpaRepository).save(captor.capture()); + assertEquals(success, captor.getValue().isSuccess()); + } + + @Test + @DisplayName("findFailuresSince queries with the normalised email and the caller's window start") + void findFailuresSinceNormalisesTheEmailAndForwardsTheWindow() { + Instant since = Instant.parse("2026-09-22T10:00:00Z"); + when(jpaRepository.findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter(anyString(), any())) + .thenReturn(List.of()); + + adapter.findFailuresSince("ANA.Perez@Example.COM", since); + + ArgumentCaptor email = ArgumentCaptor.forClass(String.class); + ArgumentCaptor from = ArgumentCaptor.forClass(Instant.class); + verify(jpaRepository).findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter(email.capture(), from.capture()); + assertEquals("ana.perez@example.com", email.getValue()); + assertEquals(since, from.getValue()); + verifyNoMoreInteractions(jpaRepository); + } + + @Test + @DisplayName("findFailuresSince hands the domain plain timestamps, in the order the repository returned them") + void findFailuresSinceTranslatesEntitiesToInstants() { + Instant since = Instant.parse("2026-09-22T10:00:00Z"); + Instant first = since.plus(1, ChronoUnit.MINUTES); + Instant second = since.plus(3, ChronoUnit.MINUTES); + when(jpaRepository.findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter("ana.perez@example.com", since)) + .thenReturn(List.of( + new LoginAttemptEntity("ana.perez@example.com", false, first), + new LoginAttemptEntity("ana.perez@example.com", false, second))); + + List failures = adapter.findFailuresSince("ana.perez@example.com", since); + + assertEquals(List.of(first, second), failures); + } + + @Test + @DisplayName("An account with no recent failures yields an empty list, never null") + void findFailuresSinceReturnsEmptyListWhenThereAreNoFailures() { + when(jpaRepository.findByEmailIgnoreCaseAndSuccessFalseAndAttemptedAtAfter(anyString(), any())) + .thenReturn(List.of()); + + List failures = adapter.findFailuresSince("ana.perez@example.com", Instant.now()); + + assertTrue(failures.isEmpty()); + } + + @Test + @DisplayName("A new login attempt entity carries no identity until the database assigns one") + void newEntityHasNoIdentityYet() { + Instant attemptedAt = Instant.parse("2026-09-22T10:15:30Z"); + + LoginAttemptEntity entity = new LoginAttemptEntity("ana.perez@example.com", false, attemptedAt); + + assertNull(entity.getId()); + assertEquals("ana.perez@example.com", entity.getEmail()); + assertFalse(entity.isSuccess()); + assertEquals(attemptedAt, entity.getAttemptedAt()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java index 721fc8c..af0593f 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java @@ -1,43 +1,462 @@ package com.codefactory.bookingplatform.auth.infrastructure.supabase; +import com.codefactory.bookingplatform.auth.domain.model.AppRole; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; import com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError; +import com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthException; import com.codefactory.bookingplatform.shared.config.SupabaseProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; +import java.io.IOException; import java.util.UUID; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.headerDoesNotExist; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; /** - * GoTrue answers OTP verification with a session carrying the user nested + * Contract tests for the Supabase Auth (GoTrue) adapter. + * + *

GoTrue answers OTP verification with a session carrying the user nested * ({"access_token": ..., "user": {...}}), not with the bare user object. - * These tests lock that parsing contract. + * These tests lock that parsing contract, the header/security contract + * (the secret key must only travel to admin endpoints) and the full decision + * table that translates provider failures into {@link UpstreamAuthError}. */ class GoTrueClientTest { + private static final String BASE = "https://demo.supabase.co"; + private static final String SECRET = "secret"; + private MockRestServiceServer server; private GoTrueClient client; @BeforeEach void setUp() { - RestClient.Builder realBuilder = RestClient.builder().baseUrl("https://demo.supabase.co"); + RestClient.Builder realBuilder = RestClient.builder().baseUrl(BASE); server = MockRestServiceServer.bindTo(realBuilder).build(); RestClient.Builder builder = mock(RestClient.Builder.class); when(builder.baseUrl(anyString())).thenReturn(builder); when(builder.build()).thenReturn(realBuilder.build()); - client = new GoTrueClient(new SupabaseProperties("https://demo.supabase.co", "secret"), builder); + client = new GoTrueClient(new SupabaseProperties(BASE, SECRET), builder); + } + + // --------------------------------------------------------------------- + // Endpoint catalogue: lets the error matrices drive every operation + // through the same table without duplicating the request plumbing. + // --------------------------------------------------------------------- + + enum Endpoint { + CREATE_USER, + DELETE_USER, + TOKEN, + VERIFY, + RESET, + RESEND, + RECOVER, + LOGOUT + } + + private void invoke(Endpoint endpoint) { + switch (endpoint) { + case CREATE_USER -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + case DELETE_USER -> client.deleteUser(UUID.randomUUID()); + case TOKEN -> client.requestPasswordToken("ana.perez@example.com", "Secret123!"); + case VERIFY -> client.verifyEmailToken("token-hash"); + case RESET -> client.resetPasswordWithToken("token-hash", "NewSecret123!"); + case RESEND -> client.resendSignupVerification("ana.perez@example.com"); + case RECOVER -> client.sendPasswordRecovery("ana.perez@example.com"); + case LOGOUT -> client.signOut("user-access-token"); + } + } + + /** 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 errorDecisionTable() { + return Stream.of( + // --- rule 1: HTTP 429 wins over every body and every context --- + Arguments.of(Endpoint.TOKEN, 429, "", UpstreamAuthError.RATE_LIMITED), + Arguments.of(Endpoint.CREATE_USER, 429, "{\"msg\":\"user not found\"}", UpstreamAuthError.RATE_LIMITED), + Arguments.of(Endpoint.VERIFY, 429, "{\"msg\":\"token has expired\"}", UpstreamAuthError.RATE_LIMITED), + Arguments.of(Endpoint.RECOVER, 429, "{\"msg\":\"email not confirmed\"}", UpstreamAuthError.RATE_LIMITED), + + // --- rule 2: body says the email is not confirmed --- + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"Email not confirmed\"}", UpstreamAuthError.EMAIL_NOT_CONFIRMED), + Arguments.of(Endpoint.TOKEN, 400, "{\"error_code\":\"email_not_confirmed\"}", UpstreamAuthError.EMAIL_NOT_CONFIRMED), + // body matching is case insensitive (toLowerCase(Locale.ROOT)) + Arguments.of(Endpoint.TOKEN, 401, "{\"msg\":\"EMAIL NOT CONFIRMED\"}", UpstreamAuthError.EMAIL_NOT_CONFIRMED), + + // --- rule 3: body says the user already exists --- + Arguments.of(Endpoint.CREATE_USER, 422, "{\"msg\":\"User already exists\"}", UpstreamAuthError.USER_ALREADY_EXISTS), + Arguments.of(Endpoint.CREATE_USER, 422, "{\"error_code\":\"user_exists\"}", UpstreamAuthError.USER_ALREADY_EXISTS), + Arguments.of(Endpoint.CREATE_USER, 400, "{\"error_code\":\"email_exists\"}", UpstreamAuthError.USER_ALREADY_EXISTS), + + // --- rule 4: body says not found, OR the status is 404 --- + Arguments.of(Endpoint.DELETE_USER, 400, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND), + Arguments.of(Endpoint.DELETE_USER, 404, "", UpstreamAuthError.USER_NOT_FOUND), + Arguments.of(Endpoint.RECOVER, 404, "", UpstreamAuthError.USER_NOT_FOUND), + + // --- rule 5: body says expired --- + Arguments.of(Endpoint.VERIFY, 400, "{\"msg\":\"Token has expired\"}", UpstreamAuthError.TOKEN_EXPIRED), + // the body beats the context: a 401 on /token that mentions an expired + // session is reported as TOKEN_EXPIRED, not INVALID_CREDENTIALS + Arguments.of(Endpoint.TOKEN, 401, "{\"msg\":\"session expired\"}", UpstreamAuthError.TOKEN_EXPIRED), + + // --- rule 6: context "token" --- + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"Invalid login credentials\"}", UpstreamAuthError.INVALID_CREDENTIALS), + Arguments.of(Endpoint.TOKEN, 401, "", UpstreamAuthError.INVALID_CREDENTIALS), + Arguments.of(Endpoint.TOKEN, 500, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.TOKEN, 403, "", UpstreamAuthError.UNAVAILABLE), + + // --- rule 7: context "verify" (shared by verifyEmailToken and resetPasswordWithToken) --- + Arguments.of(Endpoint.VERIFY, 400, "", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.VERIFY, 403, "", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.RESET, 400, "", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.RESET, 403, "{\"msg\":\"otp_disabled\"}", UpstreamAuthError.TOKEN_INVALID), + Arguments.of(Endpoint.VERIFY, 500, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.VERIFY, 401, "", UpstreamAuthError.UNAVAILABLE), + + // --- rule 8: any other context falls through to UNAVAILABLE --- + Arguments.of(Endpoint.CREATE_USER, 400, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.CREATE_USER, 401, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.DELETE_USER, 500, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.RESEND, 400, "", UpstreamAuthError.UNAVAILABLE), + Arguments.of(Endpoint.RECOVER, 503, "", UpstreamAuthError.UNAVAILABLE) + ); + } + + @ParameterizedTest(name = "[{index}] {0} HTTP {1} body={2} -> {3}") + @MethodSource("errorDecisionTable") + @DisplayName("Provider failures are translated into the agreed UpstreamAuthError catalogue") + void mapsProviderFailuresToTheErrorCatalogue(Endpoint endpoint, int status, String body, UpstreamAuthError expected) { + UpstreamAuthException ex = callExpectingFailure(endpoint, status, body); + + assertEquals(expected, ex.error()); + } + + @ParameterizedTest(name = "[{index}] HTTP {1} + body \"{2}\" -> {3}") + @MethodSource("precedenceTable") + @DisplayName("Rule precedence in the error table is first-match-wins, not most-specific-wins") + void errorTableIsFirstMatchWins(Endpoint endpoint, int status, String body, UpstreamAuthError expected) { + UpstreamAuthException ex = callExpectingFailure(endpoint, status, body); + + assertEquals(expected, ex.error()); + } + + static Stream precedenceTable() { + return Stream.of( + // 429 beats a "not found" body: the rate limit is checked first. + Arguments.of(Endpoint.DELETE_USER, 429, "{\"msg\":\"User not found\"}", UpstreamAuthError.RATE_LIMITED), + // 429 beats an "already exists" body. + Arguments.of(Endpoint.CREATE_USER, 429, "{\"msg\":\"User already exists\"}", UpstreamAuthError.RATE_LIMITED), + // "email not confirmed" beats "user not found" when both appear. + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"email not confirmed\",\"hint\":\"user not found\"}", + UpstreamAuthError.EMAIL_NOT_CONFIRMED), + // DEFECT (reported, not fixed): status 404 is evaluated BEFORE the "expired" + // rule, so an expired OTP answered with 404 is reported as USER_NOT_FOUND. + // Business-wise the caller should see TOKEN_EXPIRED. + Arguments.of(Endpoint.VERIFY, 404, "{\"msg\":\"Token has expired\"}", UpstreamAuthError.USER_NOT_FOUND), + // Same root cause: a 404 on /verify never reaches the "verify" branch, + // so an unknown/consumed token surfaces as USER_NOT_FOUND, not TOKEN_INVALID. + Arguments.of(Endpoint.VERIFY, 404, "", UpstreamAuthError.USER_NOT_FOUND), + // "not found" in the body beats the token context: a 400 on /token whose body + // mentions a missing user is USER_NOT_FOUND, not INVALID_CREDENTIALS. + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND) + ); } + @Test + @DisplayName("An unmapped status keeps the failing operation in the message for troubleshooting") + void unmappedStatusCarriesTheContextInTheMessage() { + UpstreamAuthException ex = callExpectingFailure(Endpoint.RESEND, 502, ""); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("resend"), ex.getMessage()); + assertTrue(ex.getMessage().contains("502"), ex.getMessage()); + } + + @ParameterizedTest(name = "[{index}] {0} with the provider down -> UNAVAILABLE") + @EnumSource(Endpoint.class) + @DisplayName("Every operation degrades to UNAVAILABLE when the provider is unreachable") + void networkFailureDegradesToUnavailable(Endpoint endpoint) { + server.expect(request -> { }).andRespond(request -> { + throw new IOException("connection refused"); + }); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> invoke(endpoint)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertEquals("Identity provider is unreachable", ex.getMessage()); + assertNotNull(ex.getCause()); + } + + // --------------------------------------------------------------------- + // Header / security contract + // --------------------------------------------------------------------- + + @Nested + @DisplayName("Secret key exposure") + class HeaderContract { + + @Test + @DisplayName("Admin endpoints authenticate with apikey AND a bearer secret key") + void adminEndpointsSendApiKeyAndBearerSecret() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", SECRET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + SECRET)) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(id), MediaType.APPLICATION_JSON)); + + client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + + server.verify(); + } + + @Test + @DisplayName("deleteUser targets the user id on the admin endpoint with the admin headers") + void deleteUserUsesAdminHeaders() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/admin/users/" + id)) + .andExpect(method(HttpMethod.DELETE)) + .andExpect(header("apikey", SECRET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + SECRET)) + .andRespond(withSuccess()); + + client.deleteUser(id); + + server.verify(); + } + + @ParameterizedTest(name = "[{index}] {0} must not leak the secret key in Authorization") + @CsvSource({ + "TOKEN, /token?grant_type=password", + "VERIFY, /verify", + "RESET, /verify", + "RESEND, /resend", + "RECOVER, /recover" + }) + @DisplayName("Public endpoints send only the apikey: the secret key must never be bearer-exposed") + void publicEndpointsSendOnlyTheApiKey(Endpoint endpoint, String path) { + server.expect(requestTo(BASE + path)) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", SECRET)) + .andExpect(headerDoesNotExist(HttpHeaders.AUTHORIZATION)) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"r","user":{"id":"%s","email":"ana.perez@example.com"}} + """.formatted(UUID.randomUUID()), MediaType.APPLICATION_JSON)); + + invoke(endpoint); + + server.verify(); + } + + @Test + @DisplayName("signOut authenticates as the user: bearer is the session token, never the secret key") + void signOutSendsTheUserAccessTokenAsBearer() { + server.expect(requestTo(BASE + "/logout")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", SECRET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer user-access-token")) + .andRespond(withStatus(HttpStatusCode.valueOf(204))); + + client.signOut("user-access-token"); + + server.verify(); + } + } + + // --------------------------------------------------------------------- + // createUser + // --------------------------------------------------------------------- + + @Nested + @DisplayName("createUser") + class CreateUser { + + @Test + @DisplayName("Users are provisioned unconfirmed so the email verification flow stays mandatory") + void requestsAnUnconfirmedUserWithTheRequestedRole() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.password").value("Secret123!")) + .andExpect(jsonPath("$.email_confirm").value(false)) + .andExpect(jsonPath("$.app_metadata.role").value("PROVIDER")) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(id), MediaType.APPLICATION_JSON)); + + UUID created = client.createUser("ana.perez@example.com", "Secret123!", AppRole.PROVIDER); + + assertEquals(id, created); + server.verify(); + } + + @Test + @DisplayName("A response without an id is an unusable provider answer: UNAVAILABLE") + void missingIdFieldIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + + @Test + @DisplayName("An explicit null id is treated exactly like a missing id") + void nullIdFieldIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andRespond(withSuccess("{\"id\":null}", MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + + @Test + @DisplayName("An empty body (no payload at all) is reported as UNAVAILABLE, not as a crash") + void emptyBodyIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")).andRespond(withSuccess()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + } + + // --------------------------------------------------------------------- + // requestPasswordToken + // --------------------------------------------------------------------- + + @Nested + @DisplayName("requestPasswordToken") + class RequestPasswordToken { + + @Test + @DisplayName("A successful login returns the provider session verbatim") + void returnsTheProviderSession() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.password").value("Secret123!")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","token_type":"Bearer","expires_in":7200} + """, MediaType.APPLICATION_JSON)); + + AuthTokens tokens = client.requestPasswordToken("ana.perez@example.com", "Secret123!"); + + assertEquals("jwt", tokens.accessToken()); + assertEquals("refresh", tokens.refreshToken()); + assertEquals("Bearer", tokens.tokenType()); + assertEquals(7200L, tokens.expiresIn()); + server.verify(); + } + + @Test + @DisplayName("A session without token_type/expires_in falls back to bearer and one hour") + void appliesDefaultsWhenTheProviderOmitsTokenTypeAndExpiry() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh"} + """, MediaType.APPLICATION_JSON)); + + AuthTokens tokens = client.requestPasswordToken("ana.perez@example.com", "Secret123!"); + + assertEquals("bearer", tokens.tokenType()); + assertEquals(3600L, tokens.expiresIn()); + } + + @ParameterizedTest(name = "[{index}] missing {0} -> UNAVAILABLE") + @CsvSource({ + "access_token, {\"refresh_token\":\"refresh\"}", + "refresh_token, {\"access_token\":\"jwt\"}" + }) + @DisplayName("A session missing either token is an unusable provider answer: UNAVAILABLE") + void missingTokenFieldsAreReportedAsUnavailable(String missingField, String body) { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: " + missingField), ex.getMessage()); + } + + @Test + @DisplayName("DEFECT: a non numeric expires_in escapes as a raw NumberFormatException") + void nonNumericExpiresInLeaksARawNumberFormatException() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","expires_in":"never"} + """, MediaType.APPLICATION_JSON)); + + // Expected by the contract: UpstreamAuthException(UNAVAILABLE). + // Real behaviour (GoTrueClient.java:97): Long.parseLong is outside the guarded + // conversion, so an unchecked NumberFormatException reaches the use case. + assertThrows(NumberFormatException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + } + + @Test + @DisplayName("DEFECT: a decimal expires_in (valid JSON number) also escapes as NumberFormatException") + void decimalExpiresInLeaksARawNumberFormatException() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","expires_in":3600.0} + """, MediaType.APPLICATION_JSON)); + + assertThrows(NumberFormatException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + } + } + + // --------------------------------------------------------------------- + // verify: verifyEmailToken / resetPasswordWithToken + // --------------------------------------------------------------------- + @Test @DisplayName("verifyEmailToken reads id/email from the nested session user") void verifyEmailTokenReadsNestedUser() { @@ -69,4 +488,173 @@ void verifyEmailTokenSupportsBareUser() { assertEquals("ana.perez@example.com", confirmed.email()); server.verify(); } + + @Nested + @DisplayName("verify endpoint") + class Verify { + + @Test + @DisplayName("Email verification posts the OTP hash with type=email and no password") + void emailVerificationSendsTypeEmailWithoutPassword() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.token_hash").value("token-hash")) + .andExpect(jsonPath("$.type").value("email")) + .andExpect(jsonPath("$.password").doesNotExist()) + .andRespond(withSuccess("{\"id\":\"%s\",\"email\":\"ana.perez@example.com\"}".formatted(id), + MediaType.APPLICATION_JSON)); + + client.verifyEmailToken("token-hash"); + + server.verify(); + } + + @Test + @DisplayName("A password reset posts the OTP hash with type=recovery and the new password") + void passwordResetSendsTypeRecoveryWithThePassword() { + server.expect(requestTo(BASE + "/verify")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.token_hash").value("token-hash")) + .andExpect(jsonPath("$.type").value("recovery")) + .andExpect(jsonPath("$.password").value("NewSecret123!")) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(UUID.randomUUID()), + MediaType.APPLICATION_JSON)); + + client.resetPasswordWithToken("token-hash", "NewSecret123!"); + + server.verify(); + } + + @Test + @DisplayName("A 200 with no payload is handled as an empty session, not as a null pointer") + void emptyVerifyResponseIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/verify")).andRespond(withSuccess()); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.verifyEmailToken("token-hash")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); + } + + @Test + @DisplayName("A session whose user is not an object falls back to the root payload") + void nonObjectUserFallsBackToTheRootPayload() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"user\":\"ana.perez@example.com\",\"id\":\"%s\",\"email\":\"root@example.com\"}" + .formatted(id), MediaType.APPLICATION_JSON)); + + ConfirmedUser confirmed = client.verifyEmailToken("token-hash"); + + assertEquals(id, confirmed.userId()); + assertEquals("root@example.com", confirmed.email()); + } + + @Test + @DisplayName("DEFECT: a confirmed user without email yields the literal string \"null\"") + void missingEmailBecomesTheLiteralStringNull() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(id), MediaType.APPLICATION_JSON)); + + ConfirmedUser confirmed = client.verifyEmailToken("token-hash"); + + // Expected by the contract: UNAVAILABLE (email is required to confirm a client) + // or at least a null email. Real behaviour (GoTrueClient.java:121): + // String.valueOf(null) produces the four character string "null". + assertEquals("null", confirmed.email()); + } + } + + // --------------------------------------------------------------------- + // resend / recover + // --------------------------------------------------------------------- + + @Nested + @DisplayName("resend and recover") + class ResendAndRecover { + + @Test + @DisplayName("Resending a verification asks GoTrue for a signup type email") + void resendSendsTheSignupType() { + server.expect(requestTo(BASE + "/resend")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.type").value("signup")) + .andRespond(withSuccess()); + + client.resendSignupVerification("ana.perez@example.com"); + + server.verify(); + } + + @Test + @DisplayName("Password recovery only sends the email, never the current password") + void recoverSendsOnlyTheEmail() { + server.expect(requestTo(BASE + "/recover")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.password").doesNotExist()) + .andRespond(withSuccess()); + + client.sendPasswordRecovery("ana.perez@example.com"); + + server.verify(); + } + + @Test + @DisplayName("Resending too often surfaces the provider rate limit") + void resendTooOftenIsRateLimited() { + UpstreamAuthException ex = callExpectingFailure(Endpoint.RESEND, 429, "{\"msg\":\"over_email_send_rate_limit\"}"); + + assertEquals(UpstreamAuthError.RATE_LIMITED, ex.error()); + } + } + + // --------------------------------------------------------------------- + // signOut + // --------------------------------------------------------------------- + + @Nested + @DisplayName("signOut") + class SignOut { + + @Test + @DisplayName("A successful logout completes without a payload") + void successfulLogout() { + server.expect(requestTo(BASE + "/logout")).andRespond(withStatus(HttpStatusCode.valueOf(204))); + + client.signOut("user-access-token"); + + server.verify(); + } + + @ParameterizedTest(name = "[{index}] HTTP {0} -> TOKEN_INVALID") + @CsvSource({"401", "403", "404"}) + @DisplayName("Logging out with a dead session is reported as TOKEN_INVALID, never as USER_NOT_FOUND") + void deadSessionIsTokenInvalid(int status) { + server.expect(requestTo(BASE + "/logout")) + .andRespond(withStatus(HttpStatusCode.valueOf(status)).body("{\"msg\":\"user not found\"}")); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> client.signOut("stale-token")); + + assertEquals(UpstreamAuthError.TOKEN_INVALID, ex.error()); + assertTrue(ex.getMessage().startsWith("Session is no longer valid"), ex.getMessage()); + } + + @ParameterizedTest(name = "[{index}] HTTP {0} -> {1}") + @CsvSource({ + "429, RATE_LIMITED", + "500, UNAVAILABLE", + "400, UNAVAILABLE" + }) + @DisplayName("Any other logout failure falls back to the general error table") + void otherLogoutFailuresUseTheGeneralTable(int status, UpstreamAuthError expected) { + UpstreamAuthException ex = callExpectingFailure(Endpoint.LOGOUT, status, ""); + + assertEquals(expected, ex.error()); + } + } } diff --git a/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/mapper/ClientMapperTest.java b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/mapper/ClientMapperTest.java new file mode 100644 index 0000000..d2d87b9 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/mapper/ClientMapperTest.java @@ -0,0 +1,146 @@ +package com.codefactory.bookingplatform.identity.infrastructure.mapper; + +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientEntity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * The MapStruct mapper is the only bridge between the pure domain aggregate and the JPA entity. + * These tests lock that no field is dropped in either direction: a silent loss of status or + * notification channel would break the HU-001 rules that depend on them. + */ +class ClientMapperTest { + + private final ClientMapper mapper = new ClientMapperImpl(); + + private static Client domainClient(UUID id, NotificationChannel channel, ClientStatus status) { + return new Client(id, "Ana Perez", "1017", LocalDate.of(1998, 4, 12), "ana.perez@example.com", + "3001112233", "Medellin", channel, status); + } + + private static ClientEntity entity(UUID id, NotificationChannel channel, ClientStatus status) { + ClientEntity entity = new ClientEntity(); + entity.setId(id); + entity.setFullName("Ana Perez"); + entity.setDocument("1017"); + entity.setBirthDate(LocalDate.of(1998, 4, 12)); + entity.setEmail("ana.perez@example.com"); + entity.setPhone("3001112233"); + entity.setCity("Medellin"); + entity.setNotificationChannel(channel); + entity.setStatus(status); + return entity; + } + + @ParameterizedTest(name = "[{index}] {0} / {1} survive the trip to the entity") + @CsvSource({ + "EMAIL, PENDING_VERIFICATION", + "SMS, ACTIVE", + "WHATSAPP, SUSPENDED" + }) + @DisplayName("toEntity copies every attribute, including the status and the notification channel") + void toEntityCopiesEveryAttribute(NotificationChannel channel, ClientStatus status) { + UUID id = UUID.randomUUID(); + + ClientEntity result = mapper.toEntity(domainClient(id, channel, status)); + + assertEquals(id, result.getId()); + assertEquals("Ana Perez", result.getFullName()); + assertEquals("1017", result.getDocument()); + assertEquals(LocalDate.of(1998, 4, 12), result.getBirthDate()); + assertEquals("ana.perez@example.com", result.getEmail()); + assertEquals("3001112233", result.getPhone()); + assertEquals("Medellin", result.getCity()); + assertEquals(channel, result.getNotificationChannel()); + assertEquals(status, result.getStatus()); + } + + @ParameterizedTest(name = "[{index}] {0} / {1} survive the trip to the domain") + @CsvSource({ + "EMAIL, PENDING_VERIFICATION", + "SMS, ACTIVE", + "WHATSAPP, SUSPENDED" + }) + @DisplayName("toDomain rebuilds the aggregate with every attribute, including the status") + void toDomainCopiesEveryAttribute(NotificationChannel channel, ClientStatus status) { + UUID id = UUID.randomUUID(); + + Client result = mapper.toDomain(entity(id, channel, status)); + + assertEquals(id, result.getId()); + assertEquals("Ana Perez", result.getFullName()); + assertEquals("1017", result.getDocument()); + assertEquals(LocalDate.of(1998, 4, 12), result.getBirthDate()); + assertEquals("ana.perez@example.com", result.getEmail()); + assertEquals("3001112233", result.getPhone()); + assertEquals("Medellin", result.getCity()); + assertEquals(channel, result.getNotificationChannel()); + assertEquals(status, result.getStatus()); + } + + @Test + @DisplayName("A domain to entity to domain round trip loses nothing") + void roundTripLosesNothing() { + UUID id = UUID.randomUUID(); + Client original = domainClient(id, NotificationChannel.WHATSAPP, ClientStatus.SUSPENDED); + + Client back = mapper.toDomain(mapper.toEntity(original)); + + assertEquals(original.getId(), back.getId()); + assertEquals(original.getFullName(), back.getFullName()); + assertEquals(original.getDocument(), back.getDocument()); + assertEquals(original.getBirthDate(), back.getBirthDate()); + assertEquals(original.getEmail(), back.getEmail()); + assertEquals(original.getPhone(), back.getPhone()); + assertEquals(original.getCity(), back.getCity()); + assertEquals(original.getNotificationChannel(), back.getNotificationChannel()); + assertEquals(original.getStatus(), back.getStatus()); + } + + @Test + @DisplayName("A brand new client keeps its behaviour after the round trip: only ACTIVE may confirm bookings") + void roundTripPreservesTheBusinessState() { + Client pending = Client.pendingVerification(UUID.randomUUID(), "Ana Perez", "1017", + LocalDate.of(1998, 4, 12), "ana.perez@example.com", "3001112233", "Medellin", + NotificationChannel.EMAIL); + + Client back = mapper.toDomain(mapper.toEntity(pending)); + + assertEquals(ClientStatus.PENDING_VERIFICATION, back.getStatus()); + back.verifyEmail(); + assertEquals(ClientStatus.ACTIVE, back.getStatus()); + } + + @Test + @DisplayName("Mapping a null client yields null instead of an empty entity") + void toEntityOfNullIsNull() { + assertNull(mapper.toEntity(null)); + } + + @Test + @DisplayName("Mapping a null entity yields null instead of an empty aggregate") + void toDomainOfNullIsNull() { + assertNull(mapper.toDomain(null)); + } + + @Test + @DisplayName("The mapper does not stamp the audit columns: they belong to JPA auditing") + void mappedEntityCarriesNoAuditStamps() { + ClientEntity result = mapper.toEntity(domainClient(UUID.randomUUID(), + NotificationChannel.EMAIL, ClientStatus.ACTIVE)); + + assertNull(result.getCreatedAt()); + assertNull(result.getUpdatedAt()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/persistence/ClientRepositoryAdapterTest.java b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/persistence/ClientRepositoryAdapterTest.java new file mode 100644 index 0000000..59bf892 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/infrastructure/persistence/ClientRepositoryAdapterTest.java @@ -0,0 +1,175 @@ +package com.codefactory.bookingplatform.identity.infrastructure.persistence; + +import com.codefactory.bookingplatform.identity.domain.model.Client; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.identity.infrastructure.mapper.ClientMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Translation contract of the client adapter. The JPA repository and the MapStruct mapper are + * mocked: what is asserted is the email normalisation and that every crossing of the boundary + * goes through the mapper. Real persistence lives in the Testcontainers integration tests. + */ +class ClientRepositoryAdapterTest { + + private ClientJpaRepository jpaRepository; + private ClientMapper mapper; + private ClientRepositoryAdapter adapter; + + @BeforeEach + void setUp() { + jpaRepository = mock(ClientJpaRepository.class); + mapper = mock(ClientMapper.class); + adapter = new ClientRepositoryAdapter(jpaRepository, mapper); + } + + private static Client sampleClient(UUID id) { + return new Client(id, "Ana Perez", "1017", LocalDate.of(1998, 4, 12), "ana.perez@example.com", + "3001112233", "Medellin", NotificationChannel.EMAIL, ClientStatus.ACTIVE); + } + + @Test + @DisplayName("findById returns the domain client mapped from the stored entity") + void findByIdMapsTheStoredEntity() { + UUID id = UUID.randomUUID(); + ClientEntity entity = new ClientEntity(); + Client domain = sampleClient(id); + when(jpaRepository.findById(id)).thenReturn(Optional.of(entity)); + when(mapper.toDomain(entity)).thenReturn(domain); + + Optional found = adapter.findById(id); + + assertTrue(found.isPresent()); + assertSame(domain, found.get()); + verify(mapper).toDomain(entity); + } + + @Test + @DisplayName("An unknown id yields an empty optional and the mapper is never invoked") + void findByIdOnUnknownIdReturnsEmpty() { + UUID id = UUID.randomUUID(); + when(jpaRepository.findById(id)).thenReturn(Optional.empty()); + + assertTrue(adapter.findById(id).isEmpty()); + verify(mapper, never()).toDomain(any()); + } + + @ParameterizedTest(name = "[{index}] lookup by \"{0}\" queries \"{1}\"") + @CsvSource({ + "ana.perez@example.com, ana.perez@example.com", + "ANA.PEREZ@EXAMPLE.COM, ana.perez@example.com", + "Ana.Perez@Example.Com, ana.perez@example.com" + }) + @DisplayName("Email lookups are case insensitive: the address is normalised before the query") + void findByEmailNormalisesTheEmail(String input, String expected) { + UUID id = UUID.randomUUID(); + ClientEntity entity = new ClientEntity(); + when(jpaRepository.findByEmailIgnoreCase(anyString())).thenReturn(Optional.of(entity)); + when(mapper.toDomain(entity)).thenReturn(sampleClient(id)); + + Optional found = adapter.findByEmail(input); + + ArgumentCaptor email = ArgumentCaptor.forClass(String.class); + verify(jpaRepository).findByEmailIgnoreCase(email.capture()); + assertEquals(expected, email.getValue()); + assertEquals(id, found.orElseThrow().getId()); + } + + @Test + @DisplayName("An email with no client yields an empty optional") + void findByEmailOnUnknownEmailReturnsEmpty() { + when(jpaRepository.findByEmailIgnoreCase(anyString())).thenReturn(Optional.empty()); + + assertTrue(adapter.findByEmail("ana.perez@example.com").isEmpty()); + verify(mapper, never()).toDomain(any()); + } + + @ParameterizedTest(name = "[{index}] existsByEmail(\"{0}\") queries \"{1}\"") + @CsvSource({ + "ana.perez@example.com, ana.perez@example.com", + "ANA.PEREZ@EXAMPLE.COM, ana.perez@example.com" + }) + @DisplayName("Duplicate email detection normalises the address, so casing cannot bypass uniqueness") + void existsByEmailNormalisesTheEmail(String input, String expected) { + when(jpaRepository.existsByEmailIgnoreCase(expected)).thenReturn(true); + + assertTrue(adapter.existsByEmail(input)); + + ArgumentCaptor email = ArgumentCaptor.forClass(String.class); + verify(jpaRepository).existsByEmailIgnoreCase(email.capture()); + assertEquals(expected, email.getValue()); + } + + @Test + @DisplayName("A free email address reports no duplicate") + void existsByEmailIsFalseWhenTheEmailIsFree() { + when(jpaRepository.existsByEmailIgnoreCase(anyString())).thenReturn(false); + + assertFalse(adapter.existsByEmail("ana.perez@example.com")); + } + + @ParameterizedTest(name = "[{index}] document \"{0}\" is forwarded verbatim") + @ValueSource(strings = {"1017", "cc-1017", "CC-1017"}) + @DisplayName("The document is forwarded verbatim: uniqueness is enforced by the case insensitive query") + void existsByDocumentForwardsTheDocumentVerbatim(String document) { + when(jpaRepository.existsByDocumentIgnoreCase(document)).thenReturn(true); + + assertTrue(adapter.existsByDocument(document)); + + ArgumentCaptor captured = ArgumentCaptor.forClass(String.class); + verify(jpaRepository).existsByDocumentIgnoreCase(captured.capture()); + assertEquals(document, captured.getValue()); + } + + @Test + @DisplayName("An unknown document reports no duplicate") + void existsByDocumentIsFalseWhenTheDocumentIsFree() { + when(jpaRepository.existsByDocumentIgnoreCase(anyString())).thenReturn(false); + + assertFalse(adapter.existsByDocument("1017")); + } + + @Test + @DisplayName("save crosses the boundary twice: domain to entity on the way in, entity to domain on the way out") + void saveGoesThroughTheMapperInBothDirections() { + UUID id = UUID.randomUUID(); + Client incoming = sampleClient(id); + ClientEntity toPersist = new ClientEntity(); + ClientEntity persisted = new ClientEntity(); + Client outgoing = sampleClient(id); + when(mapper.toEntity(incoming)).thenReturn(toPersist); + when(jpaRepository.save(toPersist)).thenReturn(persisted); + when(mapper.toDomain(persisted)).thenReturn(outgoing); + + Client saved = adapter.save(incoming); + + assertSame(outgoing, saved); + ArgumentCaptor captor = ArgumentCaptor.forClass(ClientEntity.class); + verify(mapper).toEntity(incoming); + verify(jpaRepository).save(captor.capture()); + assertSame(toPersist, captor.getValue()); + verify(mapper).toDomain(persisted); + } +} From 6f7cdb6086efdc0aa3919b50035bd7b03be193b5 Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:42:14 -0500 Subject: [PATCH 04/12] test: exercise the error contract and the two domain policies at their edges GlobalExceptionHandler decides the status, the body and the error code of every failed request, and not one of its sixteen branches was covered. It is now tested by calling it directly, one nested group per handler, asserting status, errorCode, type, instance and traceId on each. Two of those tests exist to prove a negative. handleUnexpected is given an exception whose message carries a JDBC connection string with a password in it, and the response is checked to contain neither the message, nor the exception class, nor a stack trace. A security property nobody had written down is now pinned, and it holds. The two domain policies get their boundaries. The password length at 7, 8, 71, 72 and 73; each character class violated on its own and all four at once; a check that isValid always agrees with violations().isEmpty(). The lock window at its exact edges: an attempt landing on now - lockWindow counts, one millisecond earlier does not, and an attempt dated after now is discarded. BusinessException is checked for what it promises rather than what it stores: that details() rejects mutation and that it copies the incoming map, so a caller cannot reach in and change an exception after throwing it. 336 new cases. The ten classes involved go to 100% of instructions and branches. Whole suite: 744 unit tests plus 21 integration, green. Coverage 94.4% -> 98.4% of instructions, 86.5% -> 94.2% of branches. --- .../auth/domain/model/AppRoleTest.java | 48 ++ .../auth/domain/model/AuthTokensTest.java | 76 +++ .../auth/domain/model/ConfirmedUserTest.java | 75 +++ .../domain/model/UpstreamAuthErrorTest.java | 57 ++ .../model/UpstreamAuthExceptionTest.java | 83 +++ .../domain/service/LoginLockPolicyTest.java | 236 +++++++ .../domain/service/PasswordPolicyTest.java | 265 ++++++++ .../shared/error/BusinessExceptionTest.java | 211 ++++++ .../shared/error/ErrorCodeTest.java | 114 ++++ .../error/GlobalExceptionHandlerTest.java | 609 ++++++++++++++++++ 10 files changed, 1774 insertions(+) create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java new file mode 100644 index 0000000..e4051ef --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java @@ -0,0 +1,48 @@ +package com.codefactory.bookingplatform.auth.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The application roles are part of the authorization contract: their names travel inside the JWT + * claims and inside the database, so both the set of roles and their spelling are frozen behaviour. + */ +class AppRoleTest { + + @Test + @DisplayName("The platform recognises exactly three roles, in the documented order") + void exposesTheThreeApplicationRoles() { + assertIterableEquals(List.of(AppRole.CLIENT, AppRole.PROVIDER, AppRole.ADMIN), + Stream.of(AppRole.values()).toList()); + } + + @ParameterizedTest(name = "{0} round-trips through its name") + @EnumSource(AppRole.class) + @DisplayName("Every role can be resolved back from its own name, which is how it is persisted and read from the token") + void everyRoleRoundTripsThroughItsName(AppRole role) { + assertEquals(role, AppRole.valueOf(role.name())); + } + + @ParameterizedTest(name = "{0} has a stable ordinal and name") + @EnumSource(AppRole.class) + @DisplayName("Every role exposes a non-null name") + void everyRoleHasAName(AppRole role) { + assertNotNull(role.name()); + } + + @Test + @DisplayName("An unknown role name is rejected instead of silently resolving to a default") + void unknownRoleNameIsRejected() { + assertThrows(IllegalArgumentException.class, () -> AppRole.valueOf("SUPERUSER")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java new file mode 100644 index 0000000..0586722 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java @@ -0,0 +1,76 @@ +package com.codefactory.bookingplatform.auth.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Value object returned by a successful authentication. Being a record it is immutable and compared + * by value, which is what lets the application layer pass it around safely. + */ +class AuthTokensTest { + + private static final AuthTokens TOKENS = new AuthTokens("access-123", "refresh-456", "bearer", 3600L); + + @Test + @DisplayName("The record exposes every token field exactly as it was built") + void exposesEveryField() { + assertEquals("access-123", TOKENS.accessToken()); + assertEquals("refresh-456", TOKENS.refreshToken()); + assertEquals("bearer", TOKENS.tokenType()); + assertEquals(3600L, TOKENS.expiresIn()); + } + + @Test + @DisplayName("Two token sets with the same values are equal, because the record is compared by value") + void equalValuesAreEqual() { + assertEquals(TOKENS, new AuthTokens("access-123", "refresh-456", "bearer", 3600L)); + } + + @Test + @DisplayName("Equal token sets share the same hash code, so they can be used as map keys") + void equalValuesShareHashCode() { + assertEquals(TOKENS.hashCode(), new AuthTokens("access-123", "refresh-456", "bearer", 3600L).hashCode()); + } + + @Test + @DisplayName("A different access token makes the value object different") + void differentAccessTokenIsNotEqual() { + assertNotEquals(TOKENS, new AuthTokens("other", "refresh-456", "bearer", 3600L)); + } + + @Test + @DisplayName("A different expiry makes the value object different") + void differentExpiryIsNotEqual() { + assertNotEquals(TOKENS, new AuthTokens("access-123", "refresh-456", "bearer", 60L)); + } + + @Test + @DisplayName("The value object is never equal to null or to another type") + void notEqualToNullOrOtherTypes() { + assertNotEquals(null, TOKENS); + assertNotEquals("access-123", TOKENS); + } + + @Test + @DisplayName("The textual form names the record and its fields, which is what ends up in the logs") + void toStringDescribesTheRecord() { + String text = TOKENS.toString(); + assertTrue(text.contains("AuthTokens"), () -> "Expected the record name in " + text); + assertTrue(text.contains("accessToken"), () -> "Expected the field names in " + text); + } + + @Test + @DisplayName("Missing tokens are accepted as null so a partial upstream response can still be modelled") + void nullFieldsAreAccepted() { + AuthTokens partial = new AuthTokens(null, null, null, 0L); + assertNull(partial.accessToken()); + assertNull(partial.refreshToken()); + assertNull(partial.tokenType()); + assertEquals(0L, partial.expiresIn()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java new file mode 100644 index 0000000..9faa37c --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java @@ -0,0 +1,75 @@ +package com.codefactory.bookingplatform.auth.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Value object describing the user whose email has just been confirmed. It is the pair the identity + * module needs to link the platform account with the identity provider account. + */ +class ConfirmedUserTest { + + private static final UUID USER_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final ConfirmedUser USER = new ConfirmedUser(USER_ID, "ana@example.com"); + + @Test + @DisplayName("The record exposes the identifier and the email exactly as they were built") + void exposesEveryField() { + assertEquals(USER_ID, USER.userId()); + assertEquals("ana@example.com", USER.email()); + } + + @Test + @DisplayName("Two confirmed users with the same identifier and email are equal") + void equalValuesAreEqual() { + assertEquals(USER, new ConfirmedUser(USER_ID, "ana@example.com")); + } + + @Test + @DisplayName("Equal confirmed users share the same hash code") + void equalValuesShareHashCode() { + assertEquals(USER.hashCode(), new ConfirmedUser(USER_ID, "ana@example.com").hashCode()); + } + + @Test + @DisplayName("A different identifier makes the value object different even when the email matches") + void differentIdentifierIsNotEqual() { + assertNotEquals(USER, new ConfirmedUser(UUID.randomUUID(), "ana@example.com")); + } + + @Test + @DisplayName("Email comparison is case sensitive, so the record does not normalise it") + void emailComparisonIsCaseSensitive() { + assertNotEquals(USER, new ConfirmedUser(USER_ID, "ANA@example.com")); + } + + @Test + @DisplayName("The value object is never equal to null or to another type") + void notEqualToNullOrOtherTypes() { + assertNotEquals(null, USER); + assertNotEquals("ana@example.com", USER); + } + + @Test + @DisplayName("The textual form names the record and its fields") + void toStringDescribesTheRecord() { + String text = USER.toString(); + assertTrue(text.contains("ConfirmedUser"), () -> "Expected the record name in " + text); + assertTrue(text.contains("email"), () -> "Expected the field names in " + text); + } + + @Test + @DisplayName("Null components are accepted, so the record does not validate on its own") + void nullComponentsAreAccepted() { + ConfirmedUser empty = new ConfirmedUser(null, null); + assertNull(empty.userId()); + assertNull(empty.email()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java new file mode 100644 index 0000000..bfbdc9f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java @@ -0,0 +1,57 @@ +package com.codefactory.bookingplatform.auth.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Catalogue of failures the identity provider can report. The adapter translates the upstream + * response into one of these, so the set is the contract between the Supabase adapter and the + * domain. + */ +class UpstreamAuthErrorTest { + + @Test + @DisplayName("The catalogue covers the eight failures the identity provider can report") + void catalogueHasTheEightKnownFailures() { + assertIterableEquals( + List.of(UpstreamAuthError.INVALID_CREDENTIALS, + UpstreamAuthError.EMAIL_NOT_CONFIRMED, + UpstreamAuthError.USER_ALREADY_EXISTS, + UpstreamAuthError.USER_NOT_FOUND, + UpstreamAuthError.TOKEN_INVALID, + UpstreamAuthError.TOKEN_EXPIRED, + UpstreamAuthError.RATE_LIMITED, + UpstreamAuthError.UNAVAILABLE), + Stream.of(UpstreamAuthError.values()).toList()); + } + + @ParameterizedTest(name = "{0} round-trips through its name") + @EnumSource(UpstreamAuthError.class) + @DisplayName("Every upstream failure can be resolved back from its own name") + void everyErrorRoundTripsThroughItsName(UpstreamAuthError error) { + assertEquals(error, UpstreamAuthError.valueOf(error.name())); + } + + @ParameterizedTest(name = "{0} has a name") + @EnumSource(UpstreamAuthError.class) + @DisplayName("Every upstream failure exposes a non-null name") + void everyErrorHasAName(UpstreamAuthError error) { + assertNotNull(error.name()); + } + + @Test + @DisplayName("An upstream failure the domain does not know about is rejected instead of being mapped blindly") + void unknownErrorNameIsRejected() { + assertThrows(IllegalArgumentException.class, () -> UpstreamAuthError.valueOf("QUOTA_EXCEEDED")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java new file mode 100644 index 0000000..50bcd3a --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java @@ -0,0 +1,83 @@ +package com.codefactory.bookingplatform.auth.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Failure raised when the identity provider rejects or cannot serve a request. It carries the + * classified {@link UpstreamAuthError} so the application layer can map it to an HTTP status + * without parsing the upstream message. + */ +class UpstreamAuthExceptionTest { + + @Test + @DisplayName("The two-argument constructor keeps the classified error and the message") + void twoArgumentConstructorKeepsErrorAndMessage() { + UpstreamAuthException ex = new UpstreamAuthException(UpstreamAuthError.INVALID_CREDENTIALS, "bad password"); + assertEquals(UpstreamAuthError.INVALID_CREDENTIALS, ex.error()); + assertEquals("bad password", ex.getMessage()); + } + + @Test + @DisplayName("The two-argument constructor leaves the cause unset") + void twoArgumentConstructorHasNoCause() { + UpstreamAuthException ex = new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "provider down"); + assertNull(ex.getCause()); + } + + @Test + @DisplayName("The three-argument constructor keeps the original failure as the cause, so the stack trace survives") + void threeArgumentConstructorKeepsTheCause() { + IOException cause = new IOException("connection reset"); + UpstreamAuthException ex = new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "provider down", cause); + assertSame(cause, ex.getCause()); + } + + @Test + @DisplayName("The three-argument constructor keeps the classified error and the message as well") + void threeArgumentConstructorKeepsErrorAndMessage() { + UpstreamAuthException ex = new UpstreamAuthException( + UpstreamAuthError.RATE_LIMITED, "too many calls", new IOException("429")); + assertEquals(UpstreamAuthError.RATE_LIMITED, ex.error()); + assertEquals("too many calls", ex.getMessage()); + } + + @ParameterizedTest(name = "{0} is preserved by the exception") + @EnumSource(UpstreamAuthError.class) + @DisplayName("Any upstream failure can be carried unchanged by the exception") + void anyErrorIsCarriedUnchanged(UpstreamAuthError error) { + assertEquals(error, new UpstreamAuthException(error, "upstream said no").error()); + } + + @Test + @DisplayName("The exception is unchecked so adapters do not have to declare it") + void exceptionIsUnchecked() { + assertInstanceOf(RuntimeException.class, + new UpstreamAuthException(UpstreamAuthError.TOKEN_EXPIRED, "expired")); + } + + @Test + @DisplayName("The exception can be thrown and caught by its own type") + void exceptionIsThrowable() { + UpstreamAuthException thrown = assertThrows(UpstreamAuthException.class, () -> { + throw new UpstreamAuthException(UpstreamAuthError.TOKEN_INVALID, "malformed token"); + }); + assertEquals(UpstreamAuthError.TOKEN_INVALID, thrown.error()); + } + + @Test + @DisplayName("A null message is accepted and reported back as null rather than as an empty string") + void nullMessageIsPreserved() { + assertNull(new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, null).getMessage()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java index f2b7c15..abe7801 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/service/LoginLockPolicyTest.java @@ -1,15 +1,33 @@ package com.codefactory.bookingplatform.auth.domain.service; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * HU-021 - sliding-window lock after repeated failed logins. + * + *

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

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

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

+ */ class PasswordPolicyTest { + private static final String TOO_SHORT = "Password must be at least 8 characters long"; + private static final String TOO_LONG = "Password must be at most 72 characters long"; + private static final String NO_UPPER = "Password must contain at least one uppercase letter"; + private static final String NO_LOWER = "Password must contain at least one lowercase letter"; + private static final String NO_DIGIT = "Password must contain at least one digit"; + private static final String NO_SPECIAL = "Password must contain at least one special character"; + + /** + * Builds a password of exactly {@code length} characters that satisfies every character-class + * rule, so the only variable left under test is the length itself. + */ + private static String passwordOfLength(int length) { + String seed = "Aa1!"; + if (length <= seed.length()) { + return seed.substring(0, length); + } + return seed + "x".repeat(length - seed.length()); + } + @Test @DisplayName("Strong password passes the policy") void strongPasswordIsValid() { @@ -40,4 +78,231 @@ private void assertEqualsAtLeast(List violations, int minimum) { assertTrue(violations.size() >= minimum, "Expected at least " + minimum + " violations but got " + violations); } + + // ---------------------------------------------------------------------------------------- + // Length: boundary value analysis around MIN_LENGTH (8) and MAX_LENGTH (72) + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Length rule") + class LengthRule { + + @Test + @DisplayName("The policy declares 8 and 72 as the accepted length boundaries") + void boundariesAreEightAndSeventyTwo() { + assertEquals(8, PasswordPolicy.MIN_LENGTH); + assertEquals(72, PasswordPolicy.MAX_LENGTH); + } + + @ParameterizedTest(name = "length {0} is rejected as too short") + @ValueSource(ints = {1, 7}) + @DisplayName("A password shorter than 8 characters is rejected for length even if it has every character class") + void belowMinimumLengthIsRejected(int length) { + List violations = PasswordPolicy.violations(passwordOfLength(length)); + assertTrue(violations.contains(TOO_SHORT), () -> "Expected the too-short violation, got " + violations); + } + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {8, 9, 71, 72}) + @DisplayName("A password between 8 and 72 characters that meets every character class is accepted") + void lengthsInsideTheRangeAreAccepted(int length) { + String password = passwordOfLength(length); + assertIterableEquals(List.of(), PasswordPolicy.violations(password), + () -> "Expected no violations for a valid password of length " + length); + } + + @ParameterizedTest(name = "length {0} is rejected as too long") + @ValueSource(ints = {73, 100}) + @DisplayName("A password longer than 72 characters is rejected for length") + void aboveMaximumLengthIsRejected(int length) { + List violations = PasswordPolicy.violations(passwordOfLength(length)); + assertTrue(violations.contains(TOO_LONG), () -> "Expected the too-long violation, got " + violations); + } + + @Test + @DisplayName("Exactly 72 characters is the last accepted length and 73 is the first rejected one") + void maximumBoundaryIsInclusive() { + assertTrue(PasswordPolicy.isValid(passwordOfLength(72))); + assertFalse(PasswordPolicy.isValid(passwordOfLength(73))); + } + + @Test + @DisplayName("Exactly 8 characters is the first accepted length and 7 is the last rejected one") + void minimumBoundaryIsInclusive() { + assertTrue(PasswordPolicy.isValid(passwordOfLength(8))); + assertFalse(PasswordPolicy.isValid(passwordOfLength(7))); + } + + @Test + @DisplayName("An over-long password whose only defect is the length reports just that one violation") + void tooLongReportsOnlyTheLengthViolation() { + assertIterableEquals(List.of(TOO_LONG), PasswordPolicy.violations(passwordOfLength(73))); + } + } + + // ---------------------------------------------------------------------------------------- + // Null and empty: the degenerate partitions + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Null and empty input") + class NullAndEmptyInput { + + @Test + @DisplayName("A null password reports only the minimum-length violation, because no character rule can be evaluated") + void nullReportsOnlyTheLengthViolation() { + assertIterableEquals(List.of(TOO_SHORT), PasswordPolicy.violations(null)); + } + + @Test + @DisplayName("An empty password breaks the length rule and all four character-class rules at once") + void emptyPasswordBreaksEveryRule() { + List violations = PasswordPolicy.violations(""); + assertIterableEquals(List.of(TOO_SHORT, NO_UPPER, NO_LOWER, NO_DIGIT, NO_SPECIAL), violations); + } + + @Test + @DisplayName("An empty password reports the four character-class violations, the only input that can break all four") + void emptyPasswordReportsTheFourCharacterClassViolations() { + List violations = PasswordPolicy.violations(""); + assertTrue(violations.containsAll(List.of(NO_UPPER, NO_LOWER, NO_DIGIT, NO_SPECIAL)), + () -> "Expected the four character-class violations, got " + violations); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("Neither null nor an empty password is ever valid") + void nullAndEmptyAreNeverValid(String password) { + assertFalse(PasswordPolicy.isValid(password)); + } + + @Test + @DisplayName("A blank password made only of spaces still fails upper, lower and digit but not the special rule") + void blankPasswordFailsEveryRuleButSpecial() { + List violations = PasswordPolicy.violations(" "); + assertIterableEquals(List.of(NO_UPPER, NO_LOWER, NO_DIGIT), violations); + } + } + + // ---------------------------------------------------------------------------------------- + // Character classes: one requirement broken at a time (decision table) + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Character-class rules") + class CharacterClassRules { + + @ParameterizedTest(name = "\"{0}\" reports exactly [{1}]") + @CsvSource({ + "str0ng!pass, Password must contain at least one uppercase letter", + "STR0NG!PASS, Password must contain at least one lowercase letter", + "Strong!Pass, Password must contain at least one digit", + "Str0ngPassw, Password must contain at least one special character" + }) + @DisplayName("Breaking a single character-class requirement reports that requirement and nothing else") + void singleMissingClassReportsOnlyThatViolation(String password, String expectedViolation) { + assertIterableEquals(List.of(expectedViolation), PasswordPolicy.violations(password)); + } + + @Test + @DisplayName("A password missing three character classes reports the three of them in rule order") + void threeMissingClassesAreAllReported() { + assertIterableEquals(List.of(NO_UPPER, NO_DIGIT, NO_SPECIAL), PasswordPolicy.violations("todolowercase")); + } + + @ParameterizedTest(name = "\"{0}\" is accepted") + @ValueSource(strings = { + "Str0ng!Pass", + "Contraseña1", + "Passw0rd here", + "Aa1€€€€€", + "Aa1¡¿test", + "Aa1_underscore", + "Aa1\tTabbed" + }) + @DisplayName("Any non-alphanumeric character counts as special, including accents, symbols, spaces and tabs") + void nonAlphanumericCharactersCountAsSpecial(String password) { + assertIterableEquals(List.of(), PasswordPolicy.violations(password), + () -> "Expected \"" + password + "\" to be accepted"); + } + + @Test + @DisplayName("A space alone satisfies the special-character requirement") + void spaceCountsAsSpecialCharacter() { + assertTrue(PasswordPolicy.isValid("Passw0rd here")); + } + + @Test + @DisplayName("An accented letter satisfies the special-character requirement because the rule is non-ASCII-alphanumeric") + void accentedLetterCountsAsSpecialCharacter() { + assertTrue(PasswordPolicy.isValid("Contraseña1")); + } + + @Test + @DisplayName("Uppercase and lowercase rules only accept ASCII letters, so a non-ASCII-only password fails both") + void nonAsciiLettersDoNotSatisfyTheCaseRules() { + List violations = PasswordPolicy.violations("ÑÑññÑÑññ1"); + assertIterableEquals(List.of(NO_UPPER, NO_LOWER), violations); + } + } + + // ---------------------------------------------------------------------------------------- + // isValid must always agree with violations().isEmpty() + // ---------------------------------------------------------------------------------------- + + static Stream passwordCorpus() { + return Stream.of( + null, + "", + " ", + "Aa1!", + passwordOfLength(7), + passwordOfLength(8), + passwordOfLength(71), + passwordOfLength(72), + passwordOfLength(73), + "str0ng!pass", + "STR0NG!PASS", + "Strong!Pass", + "Str0ngPassw", + "todolowercase", + "Contraseña1", + "Passw0rd here", + "12345678", + "!!!!!!!!", + "Str0ng!Pass"); + } + + @ParameterizedTest(name = "isValid and violations agree for [{0}]") + @MethodSource("passwordCorpus") + @DisplayName("isValid is true exactly when the violation list is empty") + void isValidAgreesWithViolations(String password) { + assertEquals(PasswordPolicy.violations(password).isEmpty(), PasswordPolicy.isValid(password), + () -> "isValid disagreed with violations() for [" + password + "]"); + } + + @Test + @DisplayName("The returned violation list is a fresh list on every call, so callers cannot poison the policy") + void violationsReturnsAFreshList() { + List first = PasswordPolicy.violations(""); + first.clear(); + assertFalse(PasswordPolicy.violations("").isEmpty(), + "Mutating a previously returned list must not affect later calls"); + } + + @Test + @DisplayName("PasswordPolicy is a utility class that cannot be instantiated") + void policyCannotBeInstantiated() throws Exception { + var constructor = PasswordPolicy.class.getDeclaredConstructor(); + assertTrue(java.lang.reflect.Modifier.isPrivate(constructor.getModifiers()), + "The only constructor must be private"); + constructor.setAccessible(true); + assertEquals(PasswordPolicy.class, constructor.newInstance().getClass()); + } + + @Test + @DisplayName("Evaluating a null password never throws a NullPointerException") + void nullPasswordNeverThrows() { + assertDoesNotThrow(() -> PasswordPolicy.violations(null)); + } } diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java new file mode 100644 index 0000000..042611a --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java @@ -0,0 +1,211 @@ +package com.codefactory.bookingplatform.shared.error; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Carrier for a rejected business rule. It pairs an {@link ErrorCode} with a message and an + * optional detail map that the exception handler publishes to the client, so the detail map must + * be a defensive, unmodifiable copy: it crosses layers and is serialised. + * + *

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

+ */ +class BusinessExceptionTest { + + @Nested + @DisplayName("Constructors") + class Constructors { + + @Test + @DisplayName("The code-only constructor falls back to the default message of the error code") + void codeOnlyUsesTheDefaultMessage() { + BusinessException ex = new BusinessException(ErrorCode.DUPLICATE_EMAIL); + assertEquals(ErrorCode.DUPLICATE_EMAIL.defaultMessage(), ex.getMessage()); + } + + @Test + @DisplayName("The code-only constructor keeps the error code") + void codeOnlyKeepsTheErrorCode() { + assertEquals(ErrorCode.DUPLICATE_EMAIL, new BusinessException(ErrorCode.DUPLICATE_EMAIL).errorCode()); + } + + @Test + @DisplayName("The code-only constructor leaves the detail map empty, so no extra field reaches the client") + void codeOnlyHasNoDetails() { + assertTrue(new BusinessException(ErrorCode.DUPLICATE_EMAIL).details().isEmpty()); + } + + @Test + @DisplayName("The code-and-message constructor overrides the default message") + void codeAndMessageOverridesTheDefaultMessage() { + BusinessException ex = new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, "too short"); + assertEquals("too short", ex.getMessage()); + } + + @Test + @DisplayName("The code-and-message constructor leaves the detail map empty") + void codeAndMessageHasNoDetails() { + assertTrue(new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, "too short").details().isEmpty()); + } + + @Test + @DisplayName("The full constructor keeps the code, the message and the details together") + void fullConstructorKeepsEverything() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid payload", Map.of("email", "must not be blank")); + assertEquals(ErrorCode.VALIDATION_ERROR, ex.errorCode()); + assertEquals("invalid payload", ex.getMessage()); + assertEquals(Map.of("email", "must not be blank"), ex.details()); + } + + @Test + @DisplayName("The full constructor preserves the insertion order of the details, which is the order shown to the client") + void fullConstructorPreservesDetailOrder() { + Map ordered = new LinkedHashMap<>(); + ordered.put("first", "1"); + ordered.put("second", "2"); + ordered.put("third", "3"); + BusinessException ex = new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", ordered); + assertIterableEquals(List.of("first", "second", "third"), ex.details().keySet()); + } + + @Test + @DisplayName("An explicitly null message is kept as null instead of being replaced by the default one") + void nullMessageIsNotReplaced() { + assertNull(new BusinessException(ErrorCode.ACCESS_DENIED, null).getMessage()); + } + + @Test + @DisplayName("An empty detail map produces an empty detail map, not a null one") + void emptyDetailsStayEmpty() { + BusinessException ex = new BusinessException(ErrorCode.ACCESS_DENIED, "denied", Map.of()); + assertTrue(ex.details().isEmpty()); + } + + @ParameterizedTest(name = "{0} can be carried") + @EnumSource(ErrorCode.class) + @DisplayName("Any error code in the catalogue can be carried by the exception") + void anyErrorCodeCanBeCarried(ErrorCode code) { + assertEquals(code, new BusinessException(code).errorCode()); + } + + @Test + @DisplayName("The exception is unchecked so use cases can reject a rule without declaring it") + void exceptionIsUnchecked() { + assertInstanceOf(RuntimeException.class, new BusinessException(ErrorCode.ACCESS_DENIED)); + } + } + + @Nested + @DisplayName("of factory") + class OfFactory { + + @Test + @DisplayName("The factory builds an exception with exactly one detail entry") + void factoryBuildsASingleDetailEntry() { + BusinessException ex = BusinessException.of( + ErrorCode.DUPLICATE_DOCUMENT, "document already used", "documentNumber", "1017245896"); + assertEquals(Map.of("documentNumber", "1017245896"), ex.details()); + } + + @Test + @DisplayName("The factory keeps the error code and the message it was given") + void factoryKeepsCodeAndMessage() { + BusinessException ex = BusinessException.of( + ErrorCode.DUPLICATE_DOCUMENT, "document already used", "documentNumber", "1017245896"); + assertEquals(ErrorCode.DUPLICATE_DOCUMENT, ex.errorCode()); + assertEquals("document already used", ex.getMessage()); + } + + @Test + @DisplayName("The detail map built by the factory is unmodifiable like any other") + void factoryDetailsAreUnmodifiable() { + BusinessException ex = BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "no such booking", "id", "42"); + assertThrows(UnsupportedOperationException.class, () -> ex.details().put("other", "x")); + } + + @Test + @DisplayName("A null detail key is rejected, because the factory relies on an immutable map") + void factoryRejectsANullDetailKey() { + assertThrows(NullPointerException.class, + () -> BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "missing", null, "42")); + } + } + + @Nested + @DisplayName("Detail map immutability") + class DetailMapImmutability { + + @Test + @DisplayName("Adding an entry to the published details is rejected, so the exception cannot be altered downstream") + void detailsRejectInsertion() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "must not be blank")); + assertThrows(UnsupportedOperationException.class, () -> ex.details().put("password", "too weak")); + } + + @Test + @DisplayName("Removing an entry from the published details is rejected") + void detailsRejectRemoval() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "must not be blank")); + assertThrows(UnsupportedOperationException.class, () -> ex.details().remove("email")); + } + + @Test + @DisplayName("Clearing the published details is rejected") + void detailsRejectClear() { + BusinessException ex = new BusinessException( + ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "must not be blank")); + assertThrows(UnsupportedOperationException.class, () -> ex.details().clear()); + } + + @Test + @DisplayName("An empty detail map is unmodifiable too") + void emptyDetailsAreUnmodifiable() { + BusinessException ex = new BusinessException(ErrorCode.ACCESS_DENIED); + assertThrows(UnsupportedOperationException.class, () -> ex.details().put("k", "v")); + } + + @Test + @DisplayName("The exception copies the caller map, so adding to the original afterwards does not leak into the exception") + void mutatingTheSourceMapDoesNotAffectTheException() { + Map source = new HashMap<>(); + source.put("email", "must not be blank"); + BusinessException ex = new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", source); + + source.put("password", "too weak"); + + assertEquals(Map.of("email", "must not be blank"), ex.details()); + } + + @Test + @DisplayName("Clearing the caller map afterwards does not empty the details already carried by the exception") + void clearingTheSourceMapDoesNotEmptyTheException() { + Map source = new HashMap<>(); + source.put("email", "must not be blank"); + BusinessException ex = new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", source); + + source.clear(); + + assertEquals(1, ex.details().size()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java new file mode 100644 index 0000000..4b3e861 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java @@ -0,0 +1,114 @@ +package com.codefactory.bookingplatform.shared.error; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpStatus; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The error catalogue is the public API contract: every code maps to one HTTP status and one + * default message, and both travel to the client inside the ProblemDetail body. + * + *

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.

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

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

+ */ +class GlobalExceptionHandlerTest { + + private static final String TRACE_ID = "0af7651916cd43dd8448eb211c80319c"; + private static final String REQUEST_URI = "/api/v1/auth/login"; + private static final String TYPE_PREFIX = "https://bookingplatform.codefactory.com/errors/"; + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + MDC.clear(); + request = new MockHttpServletRequest("POST", REQUEST_URI); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + private static Map propertiesOf(ProblemDetail problem) { + Map properties = problem.getProperties(); + assertNotNull(properties, "The handler must always publish the errorCode and traceId properties"); + return properties; + } + + /** Placeholder controller method used to build a real {@link MethodParameter}. */ + @SuppressWarnings("unused") + private void controllerMethod(String payload) { + // never invoked; only its signature is needed + } + + private MethodArgumentNotValidException methodArgumentNotValid(List errors) throws Exception { + Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("controllerMethod", String.class); + MethodParameter parameter = new MethodParameter(method, 0); + BeanPropertyBindingResult binding = new BeanPropertyBindingResult(new Object(), "payload"); + errors.forEach(binding::addError); + return new MethodArgumentNotValidException(parameter, binding); + } + + private HandlerMethodValidationException handlerMethodValidation(List errors) { + HandlerMethodValidationException ex = mock(HandlerMethodValidationException.class); + doReturn(errors).when(ex).getAllErrors(); + return ex; + } + + // ---------------------------------------------------------------------------------------- + // handleBusiness + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Business rule rejections") + class BusinessRuleRejections { + + @Test + @DisplayName("A business rejection answers with the HTTP status declared by its error code") + void statusComesFromTheErrorCode() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(HttpStatus.CONFLICT.value(), problem.getStatus()); + } + + @Test + @DisplayName("A business rejection publishes the machine-readable error code so the client can branch on it") + void publishesTheErrorCode() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals("DUPLICATE_EMAIL", propertiesOf(problem).get("errorCode")); + } + + @Test + @DisplayName("A business rejection points to the documentation page of its error code") + void publishesTheTypeUri() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(URI.create(TYPE_PREFIX + "duplicate_email"), problem.getType()); + } + + @Test + @DisplayName("A business rejection points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(URI.create(REQUEST_URI), problem.getInstance()); + } + + @Test + @DisplayName("A business rejection carries the message of the exception as the human-readable detail") + void detailIsTheExceptionMessage() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL, "ana@example.com is already registered"), request); + assertEquals("ana@example.com is already registered", problem.getDetail()); + } + + @Test + @DisplayName("A 4xx business rejection uses the reason phrase of its own status as the title") + void titleIsTheReasonPhraseForClientErrors() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.DUPLICATE_EMAIL), request); + assertEquals(HttpStatus.CONFLICT.getReasonPhrase(), problem.getTitle()); + } + + @Test + @DisplayName("A 5xx business rejection is titled as an internal error so the upstream failure is not disclosed") + void titleIsMaskedForServerErrors() { + ProblemDetail problem = handler.handleBusiness( + new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR), request); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), problem.getTitle()); + } + + @Test + @DisplayName("The details property is published when the business rejection carries details") + void detailsArePublishedWhenPresent() { + BusinessException ex = BusinessException.of( + ErrorCode.DUPLICATE_DOCUMENT, "already used", "documentNumber", "1017245896"); + ProblemDetail problem = handler.handleBusiness(ex, request); + assertEquals(Map.of("documentNumber", "1017245896"), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("The details property is omitted when the business rejection carries no details, so the body stays clean") + void detailsAreOmittedWhenEmpty() { + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + assertFalse(propertiesOf(problem).containsKey("details"), + () -> "Expected no details property, got " + propertiesOf(problem)); + } + + @ParameterizedTest(name = "{0} keeps its status and its name in the body") + @EnumSource(ErrorCode.class) + @DisplayName("Every error code in the catalogue is translated into its own status and name") + void everyErrorCodeIsTranslated(ErrorCode code) { + ProblemDetail problem = handler.handleBusiness(new BusinessException(code), request); + assertEquals(code.status().value(), problem.getStatus()); + assertEquals(code.name(), propertiesOf(problem).get("errorCode")); + } + } + + // ---------------------------------------------------------------------------------------- + // handleValidation + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Bean validation failures") + class BeanValidationFailures { + + @Test + @DisplayName("A body validation failure answers 400 with the validation error code") + void bodyValidationAnswersBadRequest() throws Exception { + ProblemDetail problem = handler.handleValidation( + methodArgumentNotValid(List.of(new FieldError("payload", "email", "must not be blank"))), request); + assertEquals(HttpStatus.BAD_REQUEST.value(), problem.getStatus()); + assertEquals("VALIDATION_ERROR", propertiesOf(problem).get("errorCode")); + } + + @Test + @DisplayName("A body validation failure lists each rejected field with its message") + void fieldErrorsAreListedByFieldName() throws Exception { + MethodArgumentNotValidException ex = methodArgumentNotValid(List.of( + new FieldError("payload", "email", "must not be blank"), + new FieldError("payload", "password", "size must be between 8 and 72"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("email", "must not be blank", "password", "size must be between 8 and 72"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A body validation failure also lists cross-field errors under the name of the validated object") + void globalErrorsAreListedByObjectName() throws Exception { + MethodArgumentNotValidException ex = methodArgumentNotValid(List.of( + new ObjectError("payload", "password and confirmation do not match"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("payload", "password and confirmation do not match"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("Field errors and cross-field errors are reported together in the same details map") + void fieldAndGlobalErrorsAreReportedTogether() throws Exception { + MethodArgumentNotValidException ex = methodArgumentNotValid(List.of( + new FieldError("payload", "email", "must not be blank"), + new ObjectError("payload", "password and confirmation do not match"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("email", "must not be blank", + "payload", "password and confirmation do not match"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure keys each error by its first resolvable code") + void parameterErrorsAreKeyedByTheirFirstCode() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable(new String[]{"Size.name"}, null, "size must be between 1 and 60"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("Size.name", "size must be between 1 and 60"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure without codes falls back to the positional argument name") + void parameterErrorsWithoutCodesFallBackToThePosition() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable((String[]) null, null, "must not be null"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("arg0", "must not be null"), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure with an empty code array also falls back to the positional name") + void parameterErrorsWithEmptyCodesFallBackToThePosition() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable(new String[0], null, "must not be null"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("arg0", "must not be null"), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("The positional fallback advances with each error, so two unnamed errors do not overwrite each other") + void positionalFallbackAdvancesPerError() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable((String[]) null, null, "must not be null"), + new DefaultMessageSourceResolvable((String[]) null, null, "must be positive"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("arg0", "must not be null", "arg1", "must be positive"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A parameter validation failure mixing named and unnamed errors keeps the index aligned with the argument position") + void namedAndUnnamedParameterErrorsCoexist() { + HandlerMethodValidationException ex = handlerMethodValidation(List.of( + new DefaultMessageSourceResolvable(new String[]{"Min.page"}, null, "must be at least 0"), + new DefaultMessageSourceResolvable((String[]) null, null, "must not be null"))); + ProblemDetail problem = handler.handleValidation(ex, request); + assertEquals(Map.of("Min.page", "must be at least 0", "arg1", "must not be null"), + propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A validation failure with no collected errors still answers 400 with an empty details map") + void emptyValidationStillAnswersBadRequest() { + ProblemDetail problem = handler.handleValidation(handlerMethodValidation(List.of()), request); + assertEquals(Map.of(), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("An exception of neither validation type is still reported as a validation error with no details") + void unknownValidationTypeYieldsEmptyDetails() { + ProblemDetail problem = handler.handleValidation(new IllegalStateException("not a validation error"), request); + assertEquals(HttpStatus.BAD_REQUEST.value(), problem.getStatus()); + assertEquals(Map.of(), propertiesOf(problem).get("details")); + } + + @Test + @DisplayName("A validation failure never echoes the raw exception message, only the generic validation detail") + void detailIsTheGenericValidationMessage() throws Exception { + ProblemDetail problem = handler.handleValidation( + methodArgumentNotValid(List.of(new FieldError("payload", "email", "must not be blank"))), request); + assertEquals(ErrorCode.VALIDATION_ERROR.defaultMessage(), problem.getDetail()); + } + + @Test + @DisplayName("A validation failure points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + ProblemDetail problem = handler.handleValidation(handlerMethodValidation(List.of()), request); + assertEquals(URI.create(REQUEST_URI), problem.getInstance()); + } + } + + // ---------------------------------------------------------------------------------------- + // handleUnreadable + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Malformed request body") + class MalformedRequestBody { + + private final HttpMessageNotReadableException unreadable = mock(HttpMessageNotReadableException.class); + + @Test + @DisplayName("An unparsable body answers 400, because the client sent something the API cannot read") + void answersBadRequest() { + assertEquals(HttpStatus.BAD_REQUEST.value(), handler.handleUnreadable(unreadable, request).getStatus()); + } + + @Test + @DisplayName("An unparsable body is reported under the validation error code") + void reportsTheValidationErrorCode() { + assertEquals("VALIDATION_ERROR", + propertiesOf(handler.handleUnreadable(unreadable, request)).get("errorCode")); + } + + @Test + @DisplayName("An unparsable body gets a fixed detail that does not disclose the parser internals") + void detailIsAFixedMessage() { + assertEquals("Malformed request body", handler.handleUnreadable(unreadable, request).getDetail()); + } + + @Test + @DisplayName("An unparsable body points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + assertEquals(URI.create(REQUEST_URI), handler.handleUnreadable(unreadable, request).getInstance()); + } + + @Test + @DisplayName("An unparsable body publishes no details property") + void publishesNoDetails() { + assertFalse(propertiesOf(handler.handleUnreadable(unreadable, request)).containsKey("details")); + } + } + + // ---------------------------------------------------------------------------------------- + // handleAccessDenied + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Access denied") + class AccessDenied { + + private final AccessDeniedException denied = new AccessDeniedException("Access is denied to /admin/users"); + + @Test + @DisplayName("A denied authorization answers 403") + void answersForbidden() { + assertEquals(HttpStatus.FORBIDDEN.value(), handler.handleAccessDenied(denied, request).getStatus()); + } + + @Test + @DisplayName("A denied authorization is reported under the access denied error code") + void reportsTheAccessDeniedErrorCode() { + assertEquals("ACCESS_DENIED", + propertiesOf(handler.handleAccessDenied(denied, request)).get("errorCode")); + } + + @Test + @DisplayName("A denied authorization answers with the generic message, never with the resource the caller was probing") + void detailDoesNotDiscloseTheProbedResource() { + ProblemDetail problem = handler.handleAccessDenied(denied, request); + assertEquals(ErrorCode.ACCESS_DENIED.defaultMessage(), problem.getDetail()); + assertFalse(String.valueOf(problem.getDetail()).contains("/admin/users")); + } + + @Test + @DisplayName("A denied authorization points to the access denied documentation page") + void publishesTheTypeUri() { + assertEquals(URI.create(TYPE_PREFIX + "access_denied"), + handler.handleAccessDenied(denied, request).getType()); + } + + @Test + @DisplayName("A denied authorization points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + assertEquals(URI.create(REQUEST_URI), handler.handleAccessDenied(denied, request).getInstance()); + } + } + + // ---------------------------------------------------------------------------------------- + // handleUnexpected - security critical: nothing internal may reach the client + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Unexpected failures") + class UnexpectedFailures { + + private static final String SECRET = "jdbc:postgresql://10.0.0.5:5432/booking?password=hunter2"; + + private final RuntimeException leaky = new IllegalStateException("connection refused for " + SECRET); + + @Test + @DisplayName("An unexpected failure answers 500") + void answersInternalServerError() { + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), + handler.handleUnexpected(leaky, request).getStatus()); + } + + @Test + @DisplayName("An unexpected failure is reported under the internal error code") + void reportsTheInternalErrorCode() { + assertEquals("INTERNAL_ERROR", + propertiesOf(handler.handleUnexpected(leaky, request)).get("errorCode")); + } + + @Test + @DisplayName("An unexpected failure is titled as an internal server error, masking the concrete exception") + void titleIsMaskedAsInternalError() { + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), + handler.handleUnexpected(leaky, request).getTitle()); + } + + @Test + @DisplayName("An unexpected failure answers with the generic internal message, not with the exception message") + void detailIsTheGenericInternalMessage() { + assertEquals(ErrorCode.INTERNAL_ERROR.defaultMessage(), + handler.handleUnexpected(leaky, request).getDetail()); + } + + @Test + @DisplayName("The connection string of the failing exception never reaches the response body") + void doesNotLeakTheExceptionMessage() { + ProblemDetail problem = handler.handleUnexpected(leaky, request); + String rendered = problem.getTitle() + "|" + problem.getDetail() + "|" + problem.getType() + + "|" + problem.getInstance() + "|" + propertiesOf(problem); + assertFalse(rendered.contains(SECRET), + () -> "The internal connection string leaked into the response: " + rendered); + } + + @Test + @DisplayName("The class name of the failing exception never reaches the response body either") + void doesNotLeakTheExceptionType() { + ProblemDetail problem = handler.handleUnexpected(leaky, request); + String rendered = problem.getTitle() + "|" + problem.getDetail() + "|" + problem.getType() + + "|" + problem.getInstance() + "|" + propertiesOf(problem); + assertFalse(rendered.contains("IllegalStateException"), + () -> "The exception type leaked into the response: " + rendered); + } + + @Test + @DisplayName("No stack trace element reaches the response body") + void doesNotLeakTheStackTrace() { + ProblemDetail problem = handler.handleUnexpected(leaky, request); + assertFalse(propertiesOf(problem).containsKey("stackTrace")); + assertFalse(propertiesOf(problem).containsKey("exception")); + } + + @Test + @DisplayName("A failure whose cause carries the secret does not leak it either") + void doesNotLeakTheCauseMessage() { + RuntimeException wrapped = new RuntimeException("wrapper", new IllegalStateException(SECRET)); + ProblemDetail problem = handler.handleUnexpected(wrapped, request); + assertFalse(String.valueOf(propertiesOf(problem)).contains(SECRET)); + } + + @Test + @DisplayName("An unexpected failure with no message at all is still answered with the generic internal message") + void handlesAnExceptionWithoutMessage() { + assertEquals(ErrorCode.INTERNAL_ERROR.defaultMessage(), + handler.handleUnexpected(new RuntimeException(), request).getDetail()); + } + + @Test + @DisplayName("An unexpected failure points the instance at the URI of the request that failed") + void instanceIsTheRequestUri() { + assertEquals(URI.create(REQUEST_URI), handler.handleUnexpected(leaky, request).getInstance()); + } + + @Test + @DisplayName("An unexpected failure points to the internal error documentation page") + void publishesTheTypeUri() { + assertEquals(URI.create(TYPE_PREFIX + "internal_error"), + handler.handleUnexpected(leaky, request).getType()); + } + } + + // ---------------------------------------------------------------------------------------- + // Trace id taken from the MDC + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Trace correlation") + class TraceCorrelation { + + @Test + @DisplayName("The trace id of the current request is copied from the MDC into the body so support can correlate the log line") + void traceIdIsCopiedFromTheMdc() { + MDC.put("traceId", TRACE_ID); + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + assertEquals(TRACE_ID, propertiesOf(problem).get("traceId")); + } + + @Test + @DisplayName("With an empty MDC the trace id is published as null instead of failing the response") + void traceIdIsNullWhenTheMdcIsEmpty() { + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + assertTrue(propertiesOf(problem).containsKey("traceId")); + assertNull(propertiesOf(problem).get("traceId")); + } + + @Test + @DisplayName("The trace id is published under the key the handler advertises as its contract") + void traceIdUsesTheAdvertisedPropertyName() { + MDC.put("traceId", TRACE_ID); + ProblemDetail problem = handler.handleUnexpected(new RuntimeException("boom"), request); + assertEquals(TRACE_ID, propertiesOf(problem).get(GlobalExceptionHandler.TRACE_ID_PROPERTY)); + } + + @Test + @DisplayName("An unrelated MDC entry is not mistaken for the trace id") + void unrelatedMdcEntriesAreIgnored() { + MDC.put("userId", "ana@example.com"); + ProblemDetail problem = handler.handleAccessDenied(new AccessDeniedException("denied"), request); + assertNull(propertiesOf(problem).get("traceId")); + assertFalse(propertiesOf(problem).containsValue("ana@example.com")); + } + + @Test + @DisplayName("Every handler publishes the trace id, not only the business one") + void everyHandlerPublishesTheTraceId() { + MDC.put("traceId", TRACE_ID); + assertEquals(TRACE_ID, propertiesOf(handler.handleValidation( + handlerMethodValidation(List.of()), request)).get("traceId")); + assertEquals(TRACE_ID, propertiesOf(handler.handleUnreadable( + mock(HttpMessageNotReadableException.class), request)).get("traceId")); + assertEquals(TRACE_ID, propertiesOf(handler.handleAccessDenied( + new AccessDeniedException("denied"), request)).get("traceId")); + } + } + + // ---------------------------------------------------------------------------------------- + // Common envelope + // ---------------------------------------------------------------------------------------- + + @Nested + @DisplayName("Common envelope") + class CommonEnvelope { + + @Test + @DisplayName("Every response carries a timestamp so the client can tell two identical failures apart") + void publishesATimestamp() { + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); + assertNotNull(propertiesOf(problem).get("timestamp")); + } + + @Test + @DisplayName("The instance follows the request, so a different endpoint is reported under its own URI") + void instanceFollowsTheRequest() { + MockHttpServletRequest other = new MockHttpServletRequest("GET", "/api/v1/bookings/42"); + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND), other); + assertEquals(URI.create("/api/v1/bookings/42"), problem.getInstance()); + } + + @Test + @DisplayName("The documentation type is the lowercase name of the error code, so every code has its own page") + void typeIsDerivedFromTheErrorCodeName() { + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID), request); + assertEquals(URI.create(TYPE_PREFIX + "verification_token_invalid"), problem.getType()); + } + + /** + * Documents current behaviour, not desired behaviour: the advice builds the instance with + * {@code URI.create(request.getRequestURI())} without encoding, so a path the servlet + * container let through with a character illegal in a URI makes the advice itself blow up + * and the client receives a bare container error page instead of a ProblemDetail. + */ + @ParameterizedTest(name = "request URI [{0}] breaks the advice") + @ValueSource(strings = {"/api/v1/bookings/a b", "/api/v1/bookings/{id}", "/api/v1/bookings/a|b"}) + @DisplayName("A request URI with a character illegal in a URI makes the advice itself fail instead of answering a problem detail") + void requestUriWithIllegalCharacterBreaksTheAdvice(String rawUri) { + MockHttpServletRequest malformed = new MockHttpServletRequest("GET", rawUri); + assertThrows(IllegalArgumentException.class, + () -> handler.handleBusiness(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND), malformed)); + } + + @Test + @DisplayName("A request URI with accented characters is reported normally, because non-ASCII characters are tolerated in a URI path") + void requestUriWithAccentsIsReportedNormally() { + MockHttpServletRequest accented = new MockHttpServletRequest("GET", "/api/v1/servicios/masaje-relajación"); + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND), accented); + assertEquals(URI.create("/api/v1/servicios/masaje-relajación"), problem.getInstance()); + } + } +} From a6802b5150fe90dffa5ee8e2ba4a1928a121e210 Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:54:19 -0500 Subject: [PATCH 05/12] test: cover the web layer and enforce a coverage floor in the build The two controllers, the JWT role converter, the trace filter and the security configuration were all at zero. The converter is the only authorisation barrier in the system and no test executed it: the integration tests inject the authorities by hand and skip it entirely. Two security properties are now pinned by tests that prove a negative. A role placed in user_metadata, which the client can edit, grants nothing: only app_metadata is read, and when both carry a role app_metadata wins. That is checked as a unit and again over HTTP through the real filter chain. And the trace filter clears the MDC in its finally block even when the chain throws, so no request inherits another request's correlation id. Every endpoint's declared status code is locked down, because a change there breaks consumers silently, and the whole ErrorCode to HTTP status table is asserted through the controllers. The DTO boundaries are checked against the validator directly: each field at its exact limit and one past it. A 90% floor on instructions and branches now fails the build. It was verified in both directions: it passes with the full suite and it does fail when coverage drops. The DTO and the properties records are no longer excluded from the measurement, because excluding them raises the number without saying anything about what is tested. docs/qa/informe-pruebas-sprint1.md records the technique behind each group of tests, the defects found and left unfixed, the acceptance criteria still unverified, and eight design cards for the team. 302 new cases. Whole suite: 1046 unit tests plus 21 integration, green. Coverage over the full scope: 100% of instructions, branches, lines and methods (2976, 156, 678 and 163 respectively). --- docs/qa/informe-pruebas-sprint1.md | 245 +++++++++ pom.xml | 48 +- .../auth/api/AuthControllerWebTest.java | 444 ++++++++++++++++ .../api/dto/AuthRequestValidationTest.java | 136 +++++ .../api/RegistrationControllerWebTest.java | 483 ++++++++++++++++++ .../RegisterClientRequestValidationTest.java | 353 +++++++++++++ ...istrationSupportRequestValidationTest.java | 80 +++ .../shared/config/ConfigurationBeansTest.java | 128 +++++ .../shared/config/SecurityConfigTest.java | 169 ++++++ .../shared/config/SecurityConfigWebTest.java | 228 +++++++++ .../config/SupabaseJwtAuthConverterTest.java | 226 ++++++++ .../observability/TraceIdFilterTest.java | 232 +++++++++ .../support/BeanValidationSupport.java | 62 +++ 13 files changed, 2831 insertions(+), 3 deletions(-) create mode 100644 docs/qa/informe-pruebas-sprint1.md create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerWebTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/api/dto/AuthRequestValidationTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerWebTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegisterClientRequestValidationTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegistrationSupportRequestValidationTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/observability/TraceIdFilterTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/support/BeanValidationSupport.java diff --git a/docs/qa/informe-pruebas-sprint1.md b/docs/qa/informe-pruebas-sprint1.md new file mode 100644 index 0000000..cbbf849 --- /dev/null +++ b/docs/qa/informe-pruebas-sprint1.md @@ -0,0 +1,245 @@ +# Informe de calidad — cobertura de pruebas del Sprint 1 + +Trabajo de QA sobre la rama `feature/qa-unit-tests`, sacada de `main`. No se modificó lógica de +negocio: todo lo añadido vive bajo `src/test/java`, salvo la configuración de medición de cobertura +en `pom.xml`. + +## Resultado + +| Métrica | Antes | Después | +|---|---|---| +| Pruebas | 48 | **1067** | +| Instrucciones | 71,5 % | **100 %** (2976/2976) | +| Ramas | 45,5 % | **100 %** (156/156) | +| Líneas | 65,0 % | **100 %** (678/678) | +| Métodos | — | **100 %** (163/163) | +| Clases medidas | 38 | **52** | + +La única clase excluida de la medición es `BookingPlatformApplication`: su `main()` solo delega en +`SpringApplication.run` y cubrirlo exigiría levantar el contexto entero sin probar nada propio. Las +ocho clases que no aparecen en el informe son interfaces y `ClientEntity`, que no tienen bytecode +propio que medir. + +El `pom.xml` incorpora un umbral del 90 % en instrucciones **y** en ramas que hace fallar el build +por debajo de esa cifra. Se fija en 90 y no en el 100 actual para dejar margen al mantenimiento. +El umbral está comprobado en los dos sentidos: pasa con la suite completa y rompe el build cuando +la cobertura cae. + +### Cómo reproducirlo + +```bash +export JAVA_HOME= +./mvnw clean verify +``` + +El informe queda en `target/site/jacoco/index.html`. El `clean` no es opcional: sin él el fichero de +ejecución acumula corridas anteriores y la cifra sale inflada. + +Dos notas de entorno. El proyecto exige **Java 21**: con un JDK 17 el build falla con +`release version 21 not supported`. Y las pruebas de integración necesitan **Docker** para +Testcontainers; se saltan con `-DskipITs`, pero entonces la cobertura que se mide es solo la de las +unitarias. + +## Cómo se probó, y por qué así + +La cobertura por sí sola no dice nada: se puede recorrer el 100 % de las líneas sin probar una sola +de las decisiones que toma el código. Por eso el criterio no fue tocar líneas sino aplicar técnicas +formales, y la métrica que importa aquí es la de **ramas**, que partía del 45,5 %. + +**Valores límite.** Donde hay un umbral, se prueba justo antes, justo en él y justo después. El +bloqueo por intentos fallidos en `max-1`, `max` y `max+1`. La longitud de contraseña en 7, 8, 71, 72 +y 73. La mayoría de edad el día antes del cumpleaños, el día mismo y el día después, más un nacido +un 29 de febrero. La ventana deslizante del bloqueo en su borde exacto: un intento que cae en +`now - lockWindow` cuenta, uno un milisegundo antes no, y uno con fecha posterior a `now` se +descarta. + +**Tabla de decisión.** `GoTrueClient.mapError` traduce las respuestas del proveedor de identidad al +vocabulario de errores del dominio, y cada traducción equivocada se convierte en un código HTTP +equivocado. Se probó como lo que es: 30 filas sobre (contexto, estado HTTP, cuerpo), más 6 filas +cuyo único fin es fijar **en qué orden** se disparan las reglas. Ahí apareció el defecto D2. + +**Transición de estados.** `Client` es una máquina de estados y se probó su tabla completa 3×3: los +tres estados contra las tres operaciones, las nueve celdas, incluidas las cuatro que deben rechazar +y las dos que son idempotentes. En las que rechazan se verifica además que el estado no se mutó. + +**Verificación de interacciones.** Varias reglas de negocio no son sobre lo que pasa, sino sobre lo +que **no** debe pasar: una cuenta bloqueada nunca llega al proveedor de identidad, una contraseña +débil tampoco, y un correo desconocido en el reenvío ni llama fuera ni lanza, que es justo lo que +mantiene cerrada la enumeración de usuarios. + +**Propiedades de seguridad como pruebas.** Tres reglas que nadie había escrito quedan ahora fijadas: +la clave secreta solo viaja a `/admin/**`; el rol solo se lee de `app_metadata` y un `role` puesto en +`user_metadata`, que el cliente sí puede editar, se ignora; y el manejador de errores no filtra el +mensaje interno de la excepción, comprobado pasándole una cadena de conexión con contraseña dentro. + +## Defectos encontrados + +No se corrigió ninguno: corregirlos es de otro rol. Las pruebas fijan el comportamiento **actual** +para que el arreglo sea visible cuando se haga. + +### D1 — Un fallo del proveedor en recuperación de contraseña sale como 500 + +`PasswordRecoveryUseCase.java:33` y `PasswordResetUseCase.java:39` relanzan la +`UpstreamAuthException` cruda. `GlobalExceptionHandler` no declara ningún manejador para esa +excepción, así que cae en el catch-all y se responde **500 INTERNAL_ERROR**. + +Esperado: `RATE_LIMITED` a 429 y `UNAVAILABLE` a 502, que es lo que sí hacen `LoginUseCase`, +`LogoutUseCase` y `UserProvisioningService`. + +Hay una segunda consecuencia, peor que el código de estado. El Javadoc de la clase promete que la +recuperación responde siempre igual para no delatar si un correo existe. Con el proveedor limitando +por volumen, la respuesta pasa de 202 a 500 y **la anti-enumeración se rompe**. + +### D2 — Un enlace de verificación caducado dice «usuario no encontrado» + +En `GoTrueClient.java:245`, la regla `body.contains("not found") || status == 404` se evalúa **antes** +que la de `expired` y antes del `switch (context)`. GoTrue responde 404 a un OTP caducado o ya +consumido, así que quien pincha un enlace vencido recibe `USER_NOT_FOUND` en vez de `TOKEN_EXPIRED`. + +Arreglo: subir la comprobación de `expired` y el `switch` por encima de la regla del 404, o acotar +`status == 404` a los contextos administrativos. + +### D3 — Una respuesta inesperada del proveedor se convierte en 500 + +Dos casos en `GoTrueClient`, los dos por la misma causa: el `try` solo captura +`RestClientResponseException` y `ResourceAccessException`, de modo que cualquier otra excepción +escapa sin mapear. + +- `GoTrueClient.java:97` — `Long.parseLong` sobre `expires_in`. Un valor no numérico, o decimal como + `3600.0` que es JSON perfectamente válido, lanza `NumberFormatException` cruda. +- `GoTrueClient.java:59` y `:120` — `UUID.fromString` sobre el `id` devuelto. Un identificador + malformado lanza `IllegalArgumentException` cruda. + +### D4 — Un correo ausente se convierte en la cadena literal `"null"` + +`GoTrueClient.java:121` 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. + +### D5 — El rol del JWT se normaliza sin `Locale` + +`SupabaseJwtAuthConverter.java:32` hace `roleValue.toUpperCase()` sin `Locale`. Bajo locale turco, +`admin` se convierte en `ADMİN` con i sin punto, con lo que `ROLE_ADMİN` no coincide con `ROLE_ADMIN` +y la autorización falla en silencio. + +No es un descuido aislado: las otras once normalizaciones del proyecto sí usan `Locale.ROOT`. El +mismo patrón aparece en `GlobalExceptionHandler.java:86` y `SecurityConfig.java:106`, donde un +`toLowerCase()` sin locale altera el URI del tipo de error. + +### D6 — El dominio depende de Spring, y ArchUnit no lo ve + +`Client.java` importa `ErrorCode`, y `ErrorCode.java:3` importa `org.springframework.http.HttpStatus`. +El dominio queda acoplado al framework, contra lo que fija el ADR-0001. + +La regla `DOMAIN_IS_FREE_OF_FRAMEWORKS` no lo detecta porque `dependOnClassesThat()` solo inspecciona +dependencias **directas**. La violación existe y el build sigue en verde, que es el peor de los casos: +una regla que da confianza sin darla. + +### D7 — Registro concurrente: 500 en vez de 409, y sin compensación + +`RegisterClientUseCase.java:46-75` comprueba y luego actúa: `existsByEmail` / `existsByDocument` y +después `save`. Dos peticiones simultáneas con el mismo correo pasan las dos comprobaciones. + +La `DataIntegrityViolationException` que sale de la restricción única no está mapeada, así que se +responde **500** en lugar de 409. Y hay un segundo efecto: como el identificador del cliente viene +asignado, el INSERT se ejecuta al confirmar la transacción, **después** de que el método retorne, de +modo que el `catch` de la línea 72 no se dispara y **la compensación no ocurre**: queda un usuario +huérfano en el proveedor de identidad. + +Este último punto es el único de la lista que **no está verificado con una prueba**: requiere una de +integración contra la restricción real. Queda como la primera tarea pendiente. + +### Hallazgos menores + +| Dónde | Qué | +|---|---| +| `RegisterClientUseCase.java:45` | El correo se pasa a minúsculas pero no se hace `trim()`, al contrario que nombre, documento, teléfono y ciudad | +| `RegisterClientUseCase.java:72-75` | Si la compensación falla, su excepción sustituye a la original y se pierde el motivo real | +| `RegisterClientUseCase.java:69` | Llamada HTTP al proveedor **dentro** de `@Transactional`: retiene conexión del pool, y el correo sale aunque la transacción revierta | +| `LoginRequest.java:11` | `password` sin `@Size`; `RegisterClientRequest` y `PasswordResetRequest` sí topan en 72 | +| `RegisterClientRequest.java:22` | El patrón del documento acepta una cadena formada solo por guiones | +| `AuthController.java:90` | `UUID.fromString(jwt.getSubject())` sin guarda: un `sub` malformado da 500 | +| `GlobalExceptionHandler.java:47` | Dos violaciones sobre el mismo campo: la segunda sobrescribe a la primera y el cliente solo ve una | +| `GlobalExceptionHandler.java:87` | `URI.create(request.getRequestURI())` sin codificar: un carácter ilegal rompe el propio manejador | +| `GlobalExceptionHandler.java:62` vs `:36` | `handleValidation` publica `details` aunque esté vacío; `handleBusiness` lo omite | +| `LoginUseCase.java:51`, `GoTrueClient.java:234` | Correo del usuario y cuerpo completo de la respuesta del proveedor en logs de nivel `WARN` | +| `SupabaseProperties.java:6` | `secret-key` sin validación y con valor por defecto vacío: la aplicación arranca sin credencial y falla en caliente | +| `AuthPolicyProperties.java:6` | `lockWindowMinutes` sin cota: un valor negativo desplaza la ventana al futuro y **nadie se bloquea nunca** | +| `UpstreamAuthException.java:3`, `BusinessException.java:7,10` | Clases serializables sin `serialVersionUID`; `details` no transitorio con tipo no serializable (`javac -Xlint:all`) | + +### Comprobado y correcto + +Tres cosas que se sospechaban y **no** son defectos, verificadas expresamente: + +- **No hay escalada de privilegios por `user_metadata`.** El conversor solo lee `app_metadata`, y si + el rol aparece en ambos gana `app_metadata`. Comprobado en unitario y de extremo a extremo contra + la cadena de filtros real. +- **El filtro de trazas no fuga contexto entre peticiones.** El `finally` limpia el MDC también + cuando la cadena lanza, y la cabecera `X-Trace-Id` se devuelve incluso en respuestas fallidas. +- **El manejador de errores no filtra información interna.** Se le pasó una excepción cuyo mensaje + contenía una cadena de conexión con contraseña y la respuesta no lleva ni el mensaje, ni la clase + de la excepción, ni la traza. + +## Criterios de aceptación que siguen sin verificar + +| Criterio | Por qué no se pudo cerrar | +|---|---| +| Validación real del JWT: emisor, expiración, JWKS | Las pruebas usan el soporte de Spring Security, que salta el decoder. Un JWKS mal configurado solo se vería en producción | +| «Enlace de un solo uso» | Se prueba el token caducado, no el reúso del mismo token, que es la propiedad que da nombre al criterio | +| El cierre de sesión invalida la sesión | Se verifica que se invoca `signOut`, no que un token posterior sea rechazado | +| Un cliente no verificado no confirma reservas | La invariante existe en `Client.canConfirmBooking()` y está probada, pero **ningún código de producción la consulta**: no hay flujo de reservas todavía | +| MFA obligatorio para administradores | No implementado, diferido en el ADR-0003 | +| Correo o documento duplicado en concurrencia | Ver D7 | + +HU-002, HU-003 y HU-004 no tienen implementación en esta rama: 16 criterios de aceptación sin código +y, por tanto, sin prueba posible. Se registran como deuda visible, no como fallo del Sprint 1. + +## Mejoras de diseño propuestas + +Huecos que hoy dificultan probar. Cada uno está redactado como card, con su criterio de aceptación. + +**QA-01 — El dominio incumple ADR-0001 y la regla que debería impedirlo no lo ve.** +Ver D6. *Criterio:* el dominio de `identity` y `auth` no depende de Spring ni directa ni +transitivamente, y existe una regla ArchUnit que falla si alguien reintroduce el acoplamiento. +*Esfuerzo:* M. + +**QA-02 — Las políticas de negocio son `static` y no se pueden sustituir.** +`PasswordPolicy.violations()` y `AgePolicy.isAdult()` son estáticos en clases `final`, invocados +desde tres casos de uso. No se puede probar `RegisterClientUseCase` con una política de edad falsa: +toda prueba arrastra la regla real. *Criterio:* ambas se inyectan como colaboradores y existe una +prueba que sustituye `AgePolicy` por un doble. *Esfuerzo:* M. + +**QA-03 — Los controladores dependen de clases concretas, no de interfaces.** +`AuthController` y `RegistrationController` importan las implementaciones de los casos de uso, +mientras que `auth` sí publica `UserProvisioning` como interfaz. La asimetría no tiene motivo. +*Criterio:* cada caso de uso consumido por un controlador se expone tras una interfaz, y una regla +ArchUnit lo obliga. *Esfuerzo:* M. + +**QA-04 — La clasificación de errores del proveedor es privada e inalcanzable.** +`GoTrueClient.mapError` concentra doce decisiones tras un método privado; solo se llega por HTTP +simulado, que es la razón de que la clase estuviera al 13 % de ramas. *Criterio:* la traducción +(estado, cuerpo) a `UpstreamAuthError` se extrae a un componente probable directamente. +*Esfuerzo:* M. + +**QA-05 — `Instant.now()` y `UUID.randomUUID()` fuera del `Clock` inyectado.** +`GlobalExceptionHandler.java:90`, `SecurityConfig.java:110` y `TraceIdFilter.java:28`, pese a existir +`ClockConfig`. El `timestamp` y el `traceId` de una respuesta de error no son aseverables. +*Criterio:* las tres reciben `Clock` por constructor y una prueba con `Clock.fixed` asevera el +`timestamp` exacto. *Esfuerzo:* S. + +**QA-06 — ArchUnit no cubre el sentido `api → infrastructure` ni los ciclos.** +Solo existe la regla inversa. Nada impide que un controlador importe un `*Adapter` o un `*Entity`. +*Criterio:* se añaden `API_DOES_NOT_DEPEND_ON_INFRASTRUCTURE`, una `layeredArchitecture()` completa +y `slices().should().beFreeOfCycles()`. *Esfuerzo:* S. + +**QA-07 — Las reglas entre módulos están cableadas a `identity` y `auth`.** +Cuando entren `catalog` y `booking`, previstos en `componentes.md`, no habrá ninguna regla que los +cubra. *Criterio:* las reglas se reescriben de forma genérica para que cualquier módulo nuevo quede +protegido sin editar el test. *Esfuerzo:* M. + +**QA-08 — El catch-all del manejador de errores puede estar tapando los códigos de Spring MVC.** +`GlobalExceptionHandler` declara `@ExceptionHandler(Exception.class)` con `@Order(HIGHEST_PRECEDENCE)`. +Queda la sospecha de que un método no permitido, un tipo de medio no soportado o una ruta inexistente +se respondan como 500 en vez de 405, 415 y 404. *Criterio:* una prueba de integración que confirme +o descarte cada uno de los tres casos. *Esfuerzo:* S. diff --git a/pom.xml b/pom.xml index 0befdd6..2408787 100644 --- a/pom.xml +++ b/pom.xml @@ -182,10 +182,17 @@ ${jacoco.version} + **/BookingPlatformApplication.class - **/*Properties.class - **/config/OpenApiConfig.class - **/dto/** @@ -212,6 +219,41 @@ report
+ + + check-coverage + verify + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.90 + + + BRANCH + COVEREDRATIO + 0.90 + + + + + +
diff --git a/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerWebTest.java b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerWebTest.java new file mode 100644 index 0000000..6e09e77 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerWebTest.java @@ -0,0 +1,444 @@ +package com.codefactory.bookingplatform.auth.api; + +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web slice for {@link AuthController}: HTTP contract of every endpoint with the + * use cases mocked. Security filters are off; the authenticated endpoints get + * their {@link JwtAuthenticationToken} injected as the request principal, which + * is exactly what the argument resolver reads at runtime. + */ +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +class AuthControllerWebTest { + + private static final String SUBJECT = "11111111-2222-3333-4444-555555555555"; + private static final String LOGIN = "/api/v1/auth/login"; + private static final String LOGOUT = "/api/v1/auth/logout"; + private static final String RECOVERY = "/api/v1/auth/password-recovery-requests"; + private static final String RESETS = "/api/v1/auth/password-resets"; + private static final String ME = "/api/v1/auth/me"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LoginUseCase loginUseCase; + + @MockitoBean + private LogoutUseCase logoutUseCase; + + @MockitoBean + private PasswordRecoveryUseCase passwordRecoveryUseCase; + + @MockitoBean + private PasswordResetUseCase passwordResetUseCase; + + private static Jwt jwt(String tokenValue, String email) { + return Jwt.withTokenValue(tokenValue) + .header("alg", "ES256") + .subject(SUBJECT) + .claim("email", email) + .issuedAt(Instant.parse("2026-09-22T10:00:00Z")) + .expiresAt(Instant.parse("2026-09-22T11:00:00Z")) + .build(); + } + + private static JwtAuthenticationToken principal(String tokenValue, String... authorities) { + List granted = java.util.Arrays.stream(authorities) + .map(a -> (GrantedAuthority) new SimpleGrantedAuthority(a)) + .toList(); + return new JwtAuthenticationToken(jwt(tokenValue, "ana.perez@example.com"), granted); + } + + private static String loginPayload() { + return "{\"email\": \"ana.perez@example.com\", \"password\": \"Str0ng!Pass\"}"; + } + + // ------------------------------------------------------------------ login + + @Test + @DisplayName("POST /login answers 200 OK") + void loginAnswers200() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /login returns accessToken, refreshToken, tokenType and expiresIn") + void loginReturnsTheTokenBundle() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.accessToken").value("access-token")) + .andExpect(jsonPath("$.refreshToken").value("refresh-token")) + .andExpect(jsonPath("$.tokenType").value("bearer")) + .andExpect(jsonPath("$.expiresIn").value(3600)); + } + + @Test + @DisplayName("SECURITY: the login response never echoes the submitted password") + void loginResponseDoesNotLeakThePassword() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + String body = mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("Str0ng!Pass"), "the plain password leaked into the login response"); + assertFalse(body.toLowerCase().contains("password"), "a password field leaked into the login response"); + } + + @Test + @DisplayName("POST /login forwards the credentials to the use case verbatim") + void loginForwardsTheCredentials() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access-token", "refresh-token", "bearer", 3600L)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isOk()); + + verify(loginUseCase).login("ana.perez@example.com", "Str0ng!Pass"); + } + + @ParameterizedTest(name = "{0} from the login use case becomes HTTP {1}") + @CsvSource({ + "INVALID_CREDENTIALS, 401", + "EMAIL_NOT_CONFIRMED, 403", + "ACCOUNT_LOCKED, 429", + "UPSTREAM_AUTH_ERROR, 502", + "RATE_LIMITED, 429", + "AUTH_TOKEN_INVALID, 401"}) + @DisplayName("Login business failures are mapped to their declared status and errorCode") + void loginFailuresAreMapped(String errorCode, int expectedStatus) throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenThrow(new BusinessException(ErrorCode.valueOf(errorCode))); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().is(expectedStatus)) + .andExpect(jsonPath("$.errorCode").value(errorCode)); + } + + @Test + @DisplayName("A locked account answers 429 and tells the caller how long to wait") + void lockedAccountExposesRetryAfterMinutes() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenThrow(new BusinessException(ErrorCode.ACCOUNT_LOCKED, + ErrorCode.ACCOUNT_LOCKED.defaultMessage(), Map.of("retryAfterMinutes", "15"))); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.errorCode").value("ACCOUNT_LOCKED")) + .andExpect(jsonPath("$.details.retryAfterMinutes").value("15")); + } + + @Test + @DisplayName("SECURITY: a rejected login does not reveal whether the email exists") + void invalidCredentialsMessageIsNeutral() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS)); + + mockMvc.perform(post(LOGIN).contentType(MediaType.APPLICATION_JSON).content(loginPayload())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.detail").value("Invalid email or password")); + } + + @ParameterizedTest(name = "login with email [{0}] is rejected with 400") + @ValueSource(strings = {"", " ", "not-an-email", "@example.com"}) + void loginRejectsInvalidEmails(String email) throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"" + email + "\", \"password\": \"Str0ng!Pass\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.details.email").isNotEmpty()); + + verify(loginUseCase, never()).login(anyString(), anyString()); + } + + @ParameterizedTest(name = "login with password [{0}] is rejected with 400") + @ValueSource(strings = {"", " "}) + void loginRejectsBlankPasswords(String password) throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\", \"password\": \"" + password + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.password").value("password is required")); + + verify(loginUseCase, never()).login(anyString(), anyString()); + } + + @Test + @DisplayName("A missing password field is rejected with 400") + void loginRejectsMissingPassword() throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.password").value("password is required")); + } + + @Test + @DisplayName("A syntactically broken login body is answered 400 VALIDATION_ERROR") + void loginRejectsBrokenJson() throws Exception { + mockMvc.perform(post(LOGIN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana@example.com\"")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.detail").value("Malformed request body")); + } + + // ----------------------------------------------------------------- logout + + @Test + @DisplayName("POST /logout answers 204 No Content with an empty body") + void logoutAnswers204() throws Exception { + mockMvc.perform(post(LOGOUT).principal(principal("the-access-token", "ROLE_CLIENT"))) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + } + + @Test + @DisplayName("POST /logout hands the raw bearer token to the use case") + void logoutForwardsTheRawToken() throws Exception { + mockMvc.perform(post(LOGOUT).principal(principal("the-access-token", "ROLE_CLIENT"))) + .andExpect(status().isNoContent()); + + verify(logoutUseCase).logout("the-access-token"); + } + + @Test + @DisplayName("A provider failure during logout surfaces as 502 UPSTREAM_AUTH_ERROR") + void logoutUpstreamFailureIsMappedTo502() throws Exception { + doThrow(new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR)) + .when(logoutUseCase).logout(anyString()); + + mockMvc.perform(post(LOGOUT).principal(principal("the-access-token", "ROLE_CLIENT"))) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.errorCode").value("UPSTREAM_AUTH_ERROR")); + } + + // --------------------------------------------------------- recovery/reset + + @Test + @DisplayName("POST /password-recovery-requests answers 202 Accepted with an empty body") + void recoveryAnswers202() throws Exception { + mockMvc.perform(post(RECOVERY) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isAccepted()) + .andExpect(content().string("")); + + verify(passwordRecoveryUseCase).requestRecovery("ana.perez@example.com"); + } + + @Test + @DisplayName("SECURITY: an unknown email also gets 202, so the endpoint does not enumerate users") + void recoveryDoesNotEnumerateUsers() throws Exception { + mockMvc.perform(post(RECOVERY) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"nobody@example.com\"}")) + .andExpect(status().isAccepted()); + } + + @ParameterizedTest(name = "recovery with email [{0}] is rejected with 400") + @ValueSource(strings = {"", " ", "not-an-email", "@example.com"}) + void recoveryRejectsInvalidEmails(String email) throws Exception { + mockMvc.perform(post(RECOVERY) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"" + email + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + + verify(passwordRecoveryUseCase, never()).requestRecovery(anyString()); + } + + @Test + @DisplayName("POST /password-resets answers 204 No Content with an empty body") + void resetAnswers204() throws Exception { + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"N3w!Password\"}")) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + verify(passwordResetUseCase).resetPassword("pkce_1a2b3c", "N3w!Password"); + } + + @Test + @DisplayName("A weak new password is answered 400 PASSWORD_TOO_WEAK with the violations") + void resetWithWeakPasswordIsRejected() throws Exception { + doThrow(new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, + ErrorCode.PASSWORD_TOO_WEAK.defaultMessage(), Map.of("violations", "at least one digit"))) + .when(passwordResetUseCase).resetPassword(anyString(), anyString()); + + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"onlyletters\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("PASSWORD_TOO_WEAK")) + .andExpect(jsonPath("$.details.violations").value("at least one digit")); + } + + @Test + @DisplayName("A consumed recovery token is answered 400 VERIFICATION_TOKEN_INVALID") + void resetWithConsumedTokenIsRejected() throws Exception { + doThrow(new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID)) + .when(passwordResetUseCase).resetPassword(anyString(), anyString()); + + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"already-used\", \"newPassword\": \"N3w!Password\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VERIFICATION_TOKEN_INVALID")); + } + + @ParameterizedTest(name = "reset with a {0} character password is rejected with 400") + @ValueSource(ints = {1, 7, 73}) + void resetRejectsPasswordsOutsideTheSizeRange(int length) throws Exception { + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"" + "a".repeat(length) + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.newPassword") + .value("newPassword must be between 8 and 72 characters")); + + verify(passwordResetUseCase, never()).resetPassword(anyString(), anyString()); + } + + @ParameterizedTest(name = "reset with tokenHash [{0}] is rejected with 400") + @ValueSource(strings = {"", " "}) + void resetRejectsBlankTokenHash(String tokenHash) throws Exception { + mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"" + tokenHash + "\", \"newPassword\": \"N3w!Password\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.tokenHash").value("tokenHash is required")); + + verify(passwordResetUseCase, never()).resetPassword(anyString(), anyString()); + } + + @Test + @DisplayName("SECURITY: the reset answer carries no body at all, so no token echo is possible") + void resetAnswerCarriesNoToken() throws Exception { + String body = mockMvc.perform(post(RESETS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\", \"newPassword\": \"N3w!Password\"}")) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("pkce_1a2b3c"), "the one-time token hash leaked into the reset response"); + } + + // --------------------------------------------------------------------- me + + @Test + @DisplayName("GET /me answers 200 with id, email and the role taken from the granted authority") + void meReturnsTheProfile() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "ROLE_CLIENT"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(SUBJECT)) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.role").value("CLIENT")); + } + + @Test + @DisplayName("GET /me strips the ROLE_ prefix from the authority") + void meStripsTheRolePrefix() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "ROLE_ADMIN"))) + .andExpect(jsonPath("$.role").value("ADMIN")); + } + + @Test + @DisplayName("GET /me ignores authorities that are not roles and reports role null") + void meIgnoresNonRoleAuthorities() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "SCOPE_read", "SCOPE_write"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").doesNotExist()); + } + + @Test + @DisplayName("GET /me reports role null when the token grants no authority at all") + void meReportsNullRoleWithoutAuthorities() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(SUBJECT)) + .andExpect(jsonPath("$.role").doesNotExist()); + } + + @Test + @DisplayName("GET /me picks the first ROLE_ authority when several are present") + void mePicksTheFirstRoleAuthority() throws Exception { + mockMvc.perform(get(ME).principal(principal("access-token", "SCOPE_read", "ROLE_PROVIDER", "ROLE_CLIENT"))) + .andExpect(jsonPath("$.role").value("PROVIDER")); + } + + @Test + @DisplayName("SECURITY: GET /me never returns the raw access token") + void meDoesNotLeakTheAccessToken() throws Exception { + String body = mockMvc.perform(get(ME).principal(principal("super-secret-access-token", "ROLE_CLIENT"))) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("super-secret-access-token"), "the access token leaked into the /me response"); + } + + @Test + @DisplayName("A subject that is not a UUID makes /me fail as a generic 500, not a leak") + void meWithNonUuidSubjectFailsSafely() throws Exception { + Jwt malformed = Jwt.withTokenValue("access-token") + .header("alg", "ES256") + .subject("not-a-uuid") + .claim("email", "ana.perez@example.com") + .issuedAt(Instant.parse("2026-09-22T10:00:00Z")) + .expiresAt(Instant.parse("2026-09-22T11:00:00Z")) + .build(); + JwtAuthenticationToken token = + new JwtAuthenticationToken(malformed, List.of(new SimpleGrantedAuthority("ROLE_CLIENT"))); + + mockMvc.perform(get(ME).principal(token)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.errorCode").value("INTERNAL_ERROR")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/api/dto/AuthRequestValidationTest.java b/src/test/java/com/codefactory/bookingplatform/auth/api/dto/AuthRequestValidationTest.java new file mode 100644 index 0000000..628127d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/api/dto/AuthRequestValidationTest.java @@ -0,0 +1,136 @@ +package com.codefactory.bookingplatform.auth.api.dto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static com.codefactory.bookingplatform.support.BeanValidationSupport.invalidProperties; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.messagesFor; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.repeat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Boundary and equivalence-class coverage for the auth module request DTOs, + * checked directly with a Jakarta Validator. + */ +class AuthRequestValidationTest { + + @Nested + @DisplayName("LoginRequest") + class Login { + + @Test + @DisplayName("A well formed login payload raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new LoginRequest("ana@example.com", "Str0ng!Pass"))); + } + + @ParameterizedTest(name = "missing, empty or blank email is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void emailIsMandatory(String value) { + assertTrue(messagesFor(new LoginRequest(value, "Str0ng!Pass"), "email") + .contains("email is required")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "a@", "@example.com", "ana example.com"}) + void emailFormatIsChecked(String value) { + assertTrue(messagesFor(new LoginRequest(value, "Str0ng!Pass"), "email") + .contains("email must be a valid address")); + } + + @ParameterizedTest(name = "missing, empty or blank password is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void passwordIsMandatory(String value) { + assertEquals(Set.of("password is required"), + messagesFor(new LoginRequest("ana@example.com", value), "password")); + } + + @Test + @DisplayName("Login does not constrain the password length: a single character is accepted") + void passwordHasNoLengthConstraint() { + assertTrue(messagesFor(new LoginRequest("ana@example.com", "x"), "password").isEmpty()); + } + } + + @Nested + @DisplayName("PasswordRecoveryRequest") + class Recovery { + + @Test + @DisplayName("A well formed recovery payload raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new PasswordRecoveryRequest("ana@example.com"))); + } + + @ParameterizedTest(name = "missing, empty or blank email is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void emailIsMandatory(String value) { + assertTrue(messagesFor(new PasswordRecoveryRequest(value), "email").contains("email is required")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "a@", "@example.com"}) + void emailFormatIsChecked(String value) { + assertTrue(messagesFor(new PasswordRecoveryRequest(value), "email") + .contains("email must be a valid address")); + } + } + + @Nested + @DisplayName("PasswordResetRequest") + class Reset { + + @Test + @DisplayName("A well formed reset payload raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new PasswordResetRequest("token-hash", "Str0ng!Pass"))); + } + + @ParameterizedTest(name = "missing, empty or blank tokenHash is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void tokenHashIsMandatory(String value) { + assertEquals(Set.of("tokenHash is required"), + messagesFor(new PasswordResetRequest(value, "Str0ng!Pass"), "tokenHash")); + } + + @ParameterizedTest(name = "newPassword of length {0} is accepted") + @ValueSource(ints = {8, 9, 71, 72}) + void acceptedPasswordLengths(int length) { + assertTrue(messagesFor(new PasswordResetRequest("token-hash", repeat('a', length)), "newPassword") + .isEmpty()); + } + + @ParameterizedTest(name = "newPassword of length {0} is outside [8, 72] and is rejected") + @ValueSource(ints = {1, 7, 73, 100}) + void rejectedPasswordLengths(int length) { + assertTrue(messagesFor(new PasswordResetRequest("token-hash", repeat('a', length)), "newPassword") + .contains("newPassword must be between 8 and 72 characters")); + } + + @ParameterizedTest(name = "missing or empty newPassword is rejected: [{0}]") + @NullSource + @ValueSource(strings = {""}) + void newPasswordIsMandatory(String value) { + assertTrue(messagesFor(new PasswordResetRequest("token-hash", value), "newPassword") + .contains("newPassword is required")); + } + + @Test + @DisplayName("A newPassword of eight spaces is long enough but still blank") + void blankNewPasswordIsRejected() { + assertEquals(Set.of("newPassword is required"), + messagesFor(new PasswordResetRequest("token-hash", " "), "newPassword")); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerWebTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerWebTest.java new file mode 100644 index 0000000..c06b493 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerWebTest.java @@ -0,0 +1,483 @@ +package com.codefactory.bookingplatform.identity.api; + +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientCommand; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.RegistrationOutcome; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import com.codefactory.bookingplatform.shared.error.BusinessException; +import com.codefactory.bookingplatform.shared.error.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web slice for {@link RegistrationController}: HTTP contract (status codes, + * response body, ProblemDetail mapping) with the use cases mocked away. + * Security filters are off so that only the web adapter is under test. + */ +@WebMvcTest(RegistrationController.class) +@AutoConfigureMockMvc(addFilters = false) +class RegistrationControllerWebTest { + + private static final UUID CLIENT_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final String REGISTRATIONS = "/api/v1/registrations"; + private static final String RESENDS = "/api/v1/registrations/verification-resends"; + private static final String CONFIRMATIONS = "/api/v1/registrations/email-verifications"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private RegisterClientUseCase registerClientUseCase; + + @MockitoBean + private ResendVerificationUseCase resendVerificationUseCase; + + @MockitoBean + private ConfirmEmailUseCase confirmEmailUseCase; + + /** Field name to raw JSON value; the insertion order is the declaration order of the DTO. */ + private static Map validFields() { + Map fields = new LinkedHashMap<>(); + fields.put("fullName", "\"Ana Maria Perez\""); + fields.put("document", "\"CC-1020304050\""); + fields.put("birthDate", "\"1995-04-10\""); + fields.put("email", "\"ana.perez@example.com\""); + fields.put("phone", "\"+573001234567\""); + fields.put("city", "\"Bogota\""); + fields.put("notificationChannel", "\"EMAIL\""); + fields.put("password", "\"Str0ng!Pass\""); + return fields; + } + + private static String json(Map fields) { + return fields.entrySet().stream() + .map(e -> "\"" + e.getKey() + "\": " + e.getValue()) + .collect(Collectors.joining(",\n", "{\n", "\n}")); + } + + private static String validRegistrationPayload() { + return json(validFields()); + } + + private static String payloadWithout(String field) { + Map fields = validFields(); + fields.remove(field); + return json(fields); + } + + private static String payloadWith(String field, String rawJsonValue) { + Map fields = validFields(); + fields.put(field, rawJsonValue); + return json(fields); + } + + // ---------------------------------------------------------------- register + + @Test + @DisplayName("POST /registrations answers 201 Created") + void registerAnswers201() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("POST /registrations returns clientId, email, status and message") + void registerReturnsTheExpectedBody() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID.toString())) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.status").value("PENDING_VERIFICATION")) + .andExpect(jsonPath("$.message").isNotEmpty()); + } + + @Test + @DisplayName("SECURITY: the registration response never echoes the password back") + void registerResponseDoesNotLeakThePassword() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + String body = mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("Str0ng!Pass"), "the plain password leaked into the response body"); + assertFalse(body.toLowerCase().contains("password"), "a password field leaked into the response body"); + } + + @Test + @DisplayName("POST /registrations maps every payload field onto the command, untouched") + void registerMapsTheWholePayloadOntoTheCommand() throws Exception { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isCreated()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegisterClientCommand.class); + verify(registerClientUseCase).register(captor.capture()); + RegisterClientCommand command = captor.getValue(); + assertEquals(new RegisterClientCommand("Ana Maria Perez", "CC-1020304050", LocalDate.of(1995, 4, 10), + "ana.perez@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"), + command); + } + + @ParameterizedTest(name = "{0} from the use case becomes HTTP {1}") + @CsvSource({ + "MINOR_NOT_ALLOWED, 400", + "DUPLICATE_EMAIL, 409", + "DUPLICATE_DOCUMENT, 409", + "PASSWORD_TOO_WEAK, 400", + "UPSTREAM_AUTH_ERROR, 502", + "RATE_LIMITED, 429", + "INTERNAL_ERROR, 500"}) + @DisplayName("A BusinessException is mapped to its declared status") + void businessExceptionsAreMappedToTheirStatus(String errorCode, int expectedStatus) throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new BusinessException(ErrorCode.valueOf(errorCode))); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().is(expectedStatus)) + .andExpect(jsonPath("$.errorCode").value(errorCode)); + } + + @Test + @DisplayName("A BusinessException carrying details exposes them under $.details") + void businessExceptionDetailsAreExposed() throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new BusinessException(ErrorCode.PASSWORD_TOO_WEAK, "weak", + Map.of("violations", "at least one digit"))); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.violations").value("at least one digit")); + } + + @Test + @DisplayName("The ProblemDetail carries type, title and instance alongside the errorCode") + void problemDetailIsFullyPopulated() throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new BusinessException(ErrorCode.MINOR_NOT_ALLOWED)); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type") + .value("https://bookingplatform.codefactory.com/errors/minor_not_allowed")) + .andExpect(jsonPath("$.title").value("Bad Request")) + .andExpect(jsonPath("$.instance").value(REGISTRATIONS)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + @DisplayName("An unexpected failure is hidden behind a generic 500 INTERNAL_ERROR") + void unexpectedFailureIsMaskedAsInternalError() throws Exception { + when(registerClientUseCase.register(any())) + .thenThrow(new IllegalStateException("connection pool exhausted at 10.0.0.7")); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(validRegistrationPayload())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.errorCode").value("INTERNAL_ERROR")) + .andExpect(jsonPath("$.detail").value("Unexpected internal error")); + } + + @ParameterizedTest(name = "a missing {0} is rejected with 400 and named in $.details") + @ValueSource(strings = {"fullName", "document", "birthDate", "email", "phone", "city", + "notificationChannel", "password"}) + @DisplayName("Every mandatory field missing is reported as VALIDATION_ERROR") + void missingMandatoryFieldsAreReported(String field) throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWithout(field))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.details." + field).isNotEmpty()); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("A blank fullName is rejected before the use case is reached") + void blankFullNameIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("fullName", "\" \""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.fullName").value("fullName is required")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("An out of range document is rejected with its pattern message") + void outOfRangeDocumentIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("document", "\"ABCD\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.document") + .value("document must be 5-20 alphanumeric characters or hyphens")); + } + + @Test + @DisplayName("Several invalid fields are all reported in one 400 answer") + void severalInvalidFieldsAreReportedTogether() throws Exception { + Map fields = validFields(); + fields.put("document", "\"A\""); + fields.put("phone", "\"12\""); + fields.put("email", "\"not-an-email\""); + + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(json(fields))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.document").isNotEmpty()) + .andExpect(jsonPath("$.details.phone").isNotEmpty()) + .andExpect(jsonPath("$.details.email").isNotEmpty()); + } + + @Test + @DisplayName("A birthDate in the future is rejected by the Past constraint, not by the use case") + void futureBirthDateIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("birthDate", "\"" + LocalDate.now().plusDays(1) + "\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.birthDate").value("birthDate must be in the past")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("A syntactically broken JSON body is answered 400 VALIDATION_ERROR") + void brokenJsonIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fullName\": \"Ana\",,,")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.detail").value("Malformed request body")); + } + + @Test + @DisplayName("An unknown notificationChannel is answered 400 VALIDATION_ERROR") + void unknownNotificationChannelIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("notificationChannel", "\"PIGEON\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("A birthDate in the wrong format is answered 400 VALIDATION_ERROR") + void malformedBirthDateIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadWith("birthDate", "\"10/04/1995\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + + verify(registerClientUseCase, never()).register(any()); + } + + @Test + @DisplayName("An empty request body is answered 400 VALIDATION_ERROR") + void emptyBodyIsRejected() throws Exception { + mockMvc.perform(post(REGISTRATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + } + + // -------------------------------------------------------------- resend + + @Test + @DisplayName("POST /verification-resends answers 202 Accepted with an empty body") + void resendAnswers202() throws Exception { + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isAccepted()) + .andExpect(content().string("")); + + verify(resendVerificationUseCase).resend("ana.perez@example.com"); + } + + @Test + @DisplayName("An unknown email still gets 202, so the endpoint does not enumerate users") + void resendDoesNotEnumerateUsers() throws Exception { + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"nobody@example.com\"}")) + .andExpect(status().isAccepted()); + } + + @ParameterizedTest(name = "resend with email [{0}] is rejected with 400") + @ValueSource(strings = {"", " ", "not-an-email", "@example.com"}) + void resendRejectsInvalidEmails(String email) throws Exception { + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"" + email + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.details.email").isNotEmpty()); + + verify(resendVerificationUseCase, never()).resend(anyString()); + } + + @Test + @DisplayName("A resend failure from the provider surfaces as 502") + void resendUpstreamFailureIsMappedTo502() throws Exception { + org.mockito.Mockito.doThrow(new BusinessException(ErrorCode.UPSTREAM_AUTH_ERROR)) + .when(resendVerificationUseCase).resend(anyString()); + + mockMvc.perform(post(RESENDS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\"}")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.errorCode").value("UPSTREAM_AUTH_ERROR")); + } + + // ------------------------------------------------------------- confirm + + @Test + @DisplayName("POST /email-verifications answers 200 OK") + void confirmAnswers200() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\"}")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /email-verifications returns the activated client and never the token hash") + void confirmReturnsTheActivatedClientWithoutTheToken() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + String body = mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\"}")) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID.toString())) + .andExpect(jsonPath("$.email").value("ana.perez@example.com")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.message").isNotEmpty()) + .andReturn().getResponse().getContentAsString(); + + assertFalse(body.contains("pkce_1a2b3c"), "the one-time token hash leaked into the response body"); + } + + @Test + @DisplayName("The confirmation token reaches the use case untouched") + void confirmForwardsTheTokenHash() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"pkce_1a2b3c\"}")) + .andExpect(status().isOk()); + + verify(confirmEmailUseCase).confirm("pkce_1a2b3c"); + } + + @ParameterizedTest(name = "confirm with tokenHash [{0}] is rejected with 400") + @ValueSource(strings = {"", " "}) + void confirmRejectsBlankTokens(String tokenHash) throws Exception { + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"" + tokenHash + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.details.tokenHash").value("tokenHash is required")); + + verify(confirmEmailUseCase, never()).confirm(anyString()); + } + + @Test + @DisplayName("A consumed or invalid token is answered 400 VERIFICATION_TOKEN_INVALID") + void confirmWithInvalidTokenIsRejected() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenThrow(new BusinessException(ErrorCode.VERIFICATION_TOKEN_INVALID)); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"already-used\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VERIFICATION_TOKEN_INVALID")); + } + + @Test + @DisplayName("A confirmed user with no client profile is answered 404 RESOURCE_NOT_FOUND") + void confirmWithoutProfileIsAnswered404() throws Exception { + when(confirmEmailUseCase.confirm(anyString())) + .thenThrow(new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Client profile not found")); + + mockMvc.perform(post(CONFIRMATIONS) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\": \"orphan-token\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value("RESOURCE_NOT_FOUND")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegisterClientRequestValidationTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegisterClientRequestValidationTest.java new file mode 100644 index 0000000..013843f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegisterClientRequestValidationTest.java @@ -0,0 +1,353 @@ +package com.codefactory.bookingplatform.identity.api.dto; + +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDate; +import java.util.Set; +import java.util.stream.Stream; + +import static com.codefactory.bookingplatform.support.BeanValidationSupport.digits; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.emailOfLength; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.invalidProperties; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.messagesFor; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.repeat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Black box boundary analysis of the registration payload, run straight against a + * Jakarta Validator. Every constraint declared on {@link RegisterClientRequest} is + * pinned on both sides of its limit (valid / invalid equivalence classes). + */ +class RegisterClientRequestValidationTest { + + private static final LocalDate ADULT_BIRTH_DATE = LocalDate.of(1995, 4, 10); + + private static RegisterClientRequest valid() { + return new RegisterClientRequest( + "Ana Maria Perez", + "CC-1020304050", + ADULT_BIRTH_DATE, + "ana.perez@example.com", + "+573001234567", + "Bogota", + NotificationChannel.EMAIL, + "Str0ng!Pass"); + } + + private static RegisterClientRequest withFullName(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(value, v.document(), v.birthDate(), v.email(), + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withDocument(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), value, v.birthDate(), v.email(), + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withBirthDate(LocalDate value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), value, v.email(), + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withEmail(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), value, + v.phone(), v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withPhone(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + value, v.city(), v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withCity(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + v.phone(), value, v.notificationChannel(), v.password()); + } + + private static RegisterClientRequest withChannel(NotificationChannel value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + v.phone(), v.city(), value, v.password()); + } + + private static RegisterClientRequest withPassword(String value) { + RegisterClientRequest v = valid(); + return new RegisterClientRequest(v.fullName(), v.document(), v.birthDate(), v.email(), + v.phone(), v.city(), v.notificationChannel(), value); + } + + @Test + @DisplayName("The reference payload raises no violation at all") + void referencePayloadIsValid() { + assertEquals(Set.of(), invalidProperties(valid())); + } + + @Nested + @DisplayName("fullName: NotBlank + Size(max = 120)") + class FullName { + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {1, 119, 120}) + void acceptedLengths(int length) { + assertTrue(messagesFor(withFullName(repeat('a', length)), "fullName").isEmpty()); + } + + @Test + @DisplayName("121 characters is one over the limit and is rejected") + void oneOverTheLimitIsRejected() { + assertEquals(Set.of("fullName must be at most 120 characters"), + messagesFor(withFullName(repeat('a', 121)), "fullName")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withFullName(value), "fullName").contains("fullName is required")); + } + } + + @Nested + @DisplayName("document: NotBlank + Pattern [A-Za-z0-9-]{5,20}") + class Document { + + @ParameterizedTest(name = "[{0}] is accepted") + @ValueSource(strings = {"ABCDE", "12345", "CC-1020304050", "ABCDEFGHIJ1234567890", "-----"}) + void acceptedDocuments(String value) { + assertTrue(messagesFor(withDocument(value), "document").isEmpty()); + } + + @ParameterizedTest(name = "[{0}] is rejected") + @ValueSource(strings = { + "ABCD", + "ABCDEFGHIJ12345678901", + "ABC DE", + "ABC_DE", + "ABCD.E"}) + void rejectedDocuments(String value) { + assertEquals(Set.of("document must be 5-20 alphanumeric characters or hyphens"), + messagesFor(withDocument(value), "document")); + } + + @Test + @DisplayName("An accented letter is outside the allowed character set") + void accentedLetterIsRejected() { + assertEquals(Set.of("document must be 5-20 alphanumeric characters or hyphens"), + messagesFor(withDocument("ABCDÉ"), "document")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withDocument(value), "document").contains("document is required")); + } + } + + @Nested + @DisplayName("birthDate: NotNull + Past") + class BirthDate { + + @Test + @DisplayName("Yesterday is in the past and is accepted") + void yesterdayIsAccepted() { + assertTrue(messagesFor(withBirthDate(LocalDate.now().minusDays(1)), "birthDate").isEmpty()); + } + + @Test + @DisplayName("Today is NOT in the past and is rejected") + void todayIsRejected() { + assertEquals(Set.of("birthDate must be in the past"), + messagesFor(withBirthDate(LocalDate.now()), "birthDate")); + } + + @Test + @DisplayName("Tomorrow is rejected") + void futureIsRejected() { + assertEquals(Set.of("birthDate must be in the past"), + messagesFor(withBirthDate(LocalDate.now().plusDays(1)), "birthDate")); + } + + @Test + @DisplayName("A null birthDate is rejected as required") + void nullIsRejected() { + assertEquals(Set.of("birthDate is required"), messagesFor(withBirthDate(null), "birthDate")); + } + } + + @Nested + @DisplayName("email: NotBlank + Email + Size(max = 160)") + class Email { + + @Test + @DisplayName("An address of exactly 160 characters is accepted") + void maxLengthIsAccepted() { + assertTrue(messagesFor(withEmail(emailOfLength(160)), "email").isEmpty()); + } + + @Test + @DisplayName("An address of 161 characters is one over the limit and is rejected") + void oneOverTheLimitIsRejected() { + assertEquals(Set.of("email must be at most 160 characters"), + messagesFor(withEmail(emailOfLength(161)), "email")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "missing-at.example.com", "a@", "@example.com", "a b@example.com"}) + void invalidFormatsAreRejected(String value) { + assertTrue(messagesFor(withEmail(value), "email").contains("email must be a valid address")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withEmail(value), "email").contains("email is required")); + } + } + + @Nested + @DisplayName("phone: NotBlank + Pattern optional plus then 7-15 digits") + class Phone { + + static Stream accepted() { + return Stream.of( + Arguments.of(digits(7)), + Arguments.of(digits(15)), + Arguments.of("+" + digits(7)), + Arguments.of("+" + digits(15))); + } + + @ParameterizedTest(name = "[{0}] is accepted") + @MethodSource("accepted") + void acceptedPhones(String value) { + assertTrue(messagesFor(withPhone(value), "phone").isEmpty()); + } + + static Stream rejected() { + return Stream.of( + Arguments.of(digits(6)), + Arguments.of(digits(16)), + Arguments.of("+" + digits(6)), + Arguments.of("+" + digits(16)), + Arguments.of("300abc4567"), + Arguments.of("+57 300 1234567"), + Arguments.of("++5730012345"), + Arguments.of("3001234567+")); + } + + @ParameterizedTest(name = "[{0}] is rejected") + @MethodSource("rejected") + void rejectedPhones(String value) { + assertEquals(Set.of("phone must contain 7-15 digits, optionally prefixed with +"), + messagesFor(withPhone(value), "phone")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withPhone(value), "phone").contains("phone is required")); + } + } + + @Nested + @DisplayName("city: NotBlank + Size(max = 80)") + class City { + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {1, 79, 80}) + void acceptedLengths(int length) { + assertTrue(messagesFor(withCity(repeat('a', length)), "city").isEmpty()); + } + + @Test + @DisplayName("81 characters is one over the limit and is rejected") + void oneOverTheLimitIsRejected() { + assertEquals(Set.of("city must be at most 80 characters"), + messagesFor(withCity(repeat('a', 81)), "city")); + } + + @ParameterizedTest(name = "missing, empty or blank value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void mandatoryValueIsRejected(String value) { + assertTrue(messagesFor(withCity(value), "city").contains("city is required")); + } + } + + @Nested + @DisplayName("password: NotBlank + Size(min = 8, max = 72)") + class Password { + + @ParameterizedTest(name = "length {0} is accepted") + @ValueSource(ints = {8, 9, 71, 72}) + void acceptedLengths(int length) { + assertTrue(messagesFor(withPassword(repeat('a', length)), "password").isEmpty()); + } + + @ParameterizedTest(name = "length {0} is outside [8, 72] and is rejected") + @CsvSource({"1", "7", "73", "100"}) + void rejectedLengths(int length) { + assertTrue(messagesFor(withPassword(repeat('a', length)), "password") + .contains("password must be between 8 and 72 characters")); + } + + @ParameterizedTest(name = "missing or empty value is rejected: [{0}]") + @NullSource + @ValueSource(strings = {""}) + void mandatoryValueIsRejected(String value) { + assertFalse(messagesFor(withPassword(value), "password").isEmpty()); + } + + @Test + @DisplayName("A password of eight spaces is long enough but still blank") + void eightSpacesIsBlank() { + assertEquals(Set.of("password is required"), messagesFor(withPassword(" "), "password")); + } + } + + @Nested + @DisplayName("notificationChannel: NotNull") + class Channel { + + @ParameterizedTest(name = "{0} is accepted") + @ValueSource(strings = {"EMAIL", "SMS", "WHATSAPP"}) + void everyDeclaredChannelIsAccepted(String value) { + assertTrue(messagesFor(withChannel(NotificationChannel.valueOf(value)), "notificationChannel").isEmpty()); + } + + @Test + @DisplayName("A null channel is rejected as required") + void nullIsRejected() { + assertEquals(Set.of("notificationChannel is required"), + messagesFor(withChannel(null), "notificationChannel")); + } + } + + @Test + @DisplayName("An all-null payload reports every mandatory field at once") + void allNullPayloadReportsEveryMandatoryField() { + RegisterClientRequest empty = new RegisterClientRequest(null, null, null, null, null, null, null, null); + assertEquals( + Set.of("fullName", "document", "birthDate", "email", "phone", "city", "notificationChannel", "password"), + invalidProperties(empty)); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegistrationSupportRequestValidationTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegistrationSupportRequestValidationTest.java new file mode 100644 index 0000000..af168aa --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/dto/RegistrationSupportRequestValidationTest.java @@ -0,0 +1,80 @@ +package com.codefactory.bookingplatform.identity.api.dto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static com.codefactory.bookingplatform.support.BeanValidationSupport.emailOfLength; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.invalidProperties; +import static com.codefactory.bookingplatform.support.BeanValidationSupport.messagesFor; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validation of the two small registration DTOs: email confirmation and + * verification resend. + */ +class RegistrationSupportRequestValidationTest { + + @Nested + @DisplayName("ConfirmEmailRequest") + class ConfirmEmail { + + @Test + @DisplayName("A non blank token hash raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new ConfirmEmailRequest("pkce_1a2b3c"))); + } + + @ParameterizedTest(name = "missing, empty or blank tokenHash is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " ", "\t"}) + void tokenHashIsMandatory(String value) { + assertEquals(Set.of("tokenHash is required"), + messagesFor(new ConfirmEmailRequest(value), "tokenHash")); + } + + @Test + @DisplayName("The token hash has no length ceiling: a very long opaque token is accepted") + void longTokenIsAccepted() { + assertTrue(messagesFor(new ConfirmEmailRequest("a".repeat(512)), "tokenHash").isEmpty()); + } + } + + @Nested + @DisplayName("ResendVerificationRequest") + class ResendVerification { + + @Test + @DisplayName("A well formed email raises no violation") + void validPayload() { + assertEquals(Set.of(), invalidProperties(new ResendVerificationRequest("ana@example.com"))); + } + + @ParameterizedTest(name = "missing, empty or blank email is rejected: [{0}]") + @NullSource + @ValueSource(strings = {"", " "}) + void emailIsMandatory(String value) { + assertTrue(messagesFor(new ResendVerificationRequest(value), "email").contains("email is required")); + } + + @ParameterizedTest(name = "[{0}] is not a valid address") + @ValueSource(strings = {"not-an-email", "a@", "@example.com", "ana perez@example.com"}) + void emailFormatIsChecked(String value) { + assertTrue(messagesFor(new ResendVerificationRequest(value), "email") + .contains("email must be a valid address")); + } + + @Test + @DisplayName("The resend DTO has no length ceiling, unlike the registration DTO") + void noMaxLengthConstraint() { + String longButValid = emailOfLength(190); + assertTrue(messagesFor(new ResendVerificationRequest(longButValid), "email").isEmpty()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java new file mode 100644 index 0000000..d6aba3d --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java @@ -0,0 +1,128 @@ +package com.codefactory.bookingplatform.shared.config; + +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.auth.infrastructure.config.AuthConfig; +import com.codefactory.bookingplatform.shared.persistence.JpaAuditingConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.time.Clock; +import java.time.Duration; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The declarative configuration classes are exercised by calling their factory + * methods directly: no application context is started, which keeps the check on + * the values produced rather than on Spring itself. + */ +class ConfigurationBeansTest { + + @Nested + @DisplayName("ClockConfig") + class ClockConfigTest { + + private final ClockConfig config = new ClockConfig(); + + @Test + @DisplayName("The clock bean is built and is not null") + void clockIsProvided() { + assertNotNull(config.clock()); + } + + @Test + @DisplayName("The clock runs on UTC, so stored timestamps do not drift with the host zone") + void clockIsUtc() { + assertEquals(ZoneOffset.UTC, config.clock().getZone()); + } + + @Test + @DisplayName("The clock is the system UTC clock, not a fixed or offset one") + void clockIsSystemUtc() { + assertEquals(Clock.systemUTC(), config.clock()); + } + + @Test + @DisplayName("The clock ticks with real time instead of returning a frozen instant") + void clockIsNotFixed() { + Clock clock = config.clock(); + + assertTrue(clock.instant().isAfter(java.time.Instant.now().minusSeconds(60))); + } + } + + @Nested + @DisplayName("AuthConfig") + class AuthConfigTest { + + private final AuthConfig config = new AuthConfig(); + + @ParameterizedTest(name = "maxFailedAttempts={0} and lockWindowMinutes={1} reach the policy untouched") + @CsvSource({"5, 15", "1, 1", "10, 60", "3, 120"}) + @DisplayName("The lock policy bean is built from the configured properties") + void policyIsBuiltFromProperties(int attempts, int minutes) { + LoginLockPolicy policy = config.loginLockPolicy(new AuthPolicyProperties(attempts, minutes)); + + assertEquals(attempts, policy.maxFailedAttempts()); + assertEquals(Duration.ofMinutes(minutes), policy.lockWindow()); + } + + @Test + @DisplayName("The production defaults are 5 attempts within a 15 minute window") + void productionDefaults() { + LoginLockPolicy policy = config.loginLockPolicy(new AuthPolicyProperties(5, 15)); + + assertEquals(5, policy.maxFailedAttempts()); + assertEquals(15, policy.lockWindow().toMinutes()); + } + + @ParameterizedTest(name = "a non positive maxFailedAttempts of {0} is refused at startup") + @CsvSource({"0", "-1"}) + void nonPositiveAttemptsAreRefused(int attempts) { + AuthPolicyProperties properties = new AuthPolicyProperties(attempts, 15); + + assertThrows(IllegalArgumentException.class, () -> config.loginLockPolicy(properties)); + } + + @Test + @DisplayName("A zero minute window produces a zero duration, disabling the sliding window") + void zeroMinuteWindow() { + LoginLockPolicy policy = config.loginLockPolicy(new AuthPolicyProperties(5, 0)); + + assertEquals(Duration.ZERO, policy.lockWindow()); + } + } + + @Nested + @DisplayName("JpaAuditingConfig") + class JpaAuditingConfigTest { + + @Test + @DisplayName("Auditing is enabled declaratively, which is what fills createdAt and updatedAt") + void auditingIsEnabled() { + assertNotNull(JpaAuditingConfig.class.getAnnotation(EnableJpaAuditing.class)); + } + + @Test + @DisplayName("The class is a Spring configuration, so the annotation is actually processed") + void isAConfigurationClass() { + assertNotNull(JpaAuditingConfig.class.getAnnotation(Configuration.class)); + } + + @Test + @DisplayName("The configuration class can be instantiated by the container") + void isInstantiable() { + assertNotNull(new JpaAuditingConfig()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java new file mode 100644 index 0000000..eeb531c --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java @@ -0,0 +1,169 @@ +package com.codefactory.bookingplatform.shared.config; + +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.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.test.util.ReflectionTestUtils; +import tools.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the pieces of {@link SecurityConfig} that produce a response: + * the JWT decoder bean and the two ProblemDetail writers used when a request is + * unauthenticated (401) or not allowed (403). + */ +class SecurityConfigTest { + + private static final SecurityProperties PROPERTIES = new SecurityProperties( + "http://localhost:54321/auth/v1", + "http://localhost:54321/auth/v1/.well-known/jwks.json"); + + private SecurityConfig config; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + config = new SecurityConfig(PROPERTIES, new ObjectMapper()); + request = new MockHttpServletRequest("GET", "/api/v1/auth/me"); + request.setRequestURI("/api/v1/auth/me"); + response = new MockHttpServletResponse(); + MDC.clear(); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + private AuthenticationEntryPoint entryPoint() { + return ReflectionTestUtils.invokeMethod(config, "problemDetailEntryPoint"); + } + + private AccessDeniedHandler accessDeniedHandler() { + return ReflectionTestUtils.invokeMethod(config, "problemDetailAccessDeniedHandler"); + } + + @Test + @DisplayName("The JWT decoder bean is built from the configured JWKS endpoint") + void jwtDecoderIsBuilt() { + JwtDecoder decoder = config.jwtDecoder(); + + assertInstanceOf(NimbusJwtDecoder.class, decoder); + } + + @Test + @DisplayName("A missing token is answered 401") + void unauthenticatedRequestGets401() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(401, response.getStatus()); + } + + @Test + @DisplayName("The 401 answer is a ProblemDetail document") + void unauthenticatedRequestGetsProblemJson() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(MediaType.APPLICATION_PROBLEM_JSON_VALUE, response.getContentType()); + } + + @Test + @DisplayName("The 401 answer carries the AUTH_REQUIRED error code") + void unauthenticatedRequestCarriesTheErrorCode() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertTrue(response.getContentAsString().contains("AUTH_REQUIRED"), + "expected AUTH_REQUIRED in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("The 401 answer points at the requested path") + void unauthenticatedRequestCarriesTheInstance() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertTrue(response.getContentAsString().contains("/api/v1/auth/me"), + "expected the request URI in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("The 401 answer propagates the current trace id so the caller can report it") + void unauthenticatedRequestCarriesTheTraceId() throws Exception { + MDC.put("traceId", "trace-4711"); + + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertTrue(response.getContentAsString().contains("trace-4711"), + "expected the trace id in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("A request without a trace id in the MDC is still answered, with no trace id") + void unauthenticatedRequestWithoutTraceIdStillAnswers() throws Exception { + entryPoint().commence(request, response, new StubAuthenticationException()); + + assertEquals(401, response.getStatus()); + } + + @Test + @DisplayName("An authenticated but unauthorised request is answered 403") + void forbiddenRequestGets403() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertEquals(403, response.getStatus()); + } + + @Test + @DisplayName("The 403 answer carries the ACCESS_DENIED error code") + void forbiddenRequestCarriesTheErrorCode() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertTrue(response.getContentAsString().contains("ACCESS_DENIED"), + "expected ACCESS_DENIED in the body but got: " + response.getContentAsString()); + } + + @Test + @DisplayName("The 403 answer is a ProblemDetail document") + void forbiddenRequestGetsProblemJson() throws Exception { + accessDeniedHandler().handle(request, response, new AccessDeniedException("nope")); + + assertEquals(MediaType.APPLICATION_PROBLEM_JSON_VALUE, response.getContentType()); + } + + @Test + @DisplayName("SECURITY: neither error answer leaks the underlying exception message") + void errorAnswersDoNotLeakInternals() throws Exception { + accessDeniedHandler().handle(request, response, + new AccessDeniedException("role ROLE_ADMIN required on method ClientAdminService.delete")); + + assertTrue(!response.getContentAsString().contains("ClientAdminService"), + "the internal message leaked into the body: " + response.getContentAsString()); + } + + @Test + @DisplayName("The entry point is reused for both the resource server and the generic handling") + void entryPointIsAvailable() { + assertNotNull(entryPoint()); + } + + private static final class StubAuthenticationException extends AuthenticationException { + StubAuthenticationException() { + super("Full authentication is required to access this resource"); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java new file mode 100644 index 0000000..123b377 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigWebTest.java @@ -0,0 +1,228 @@ +package com.codefactory.bookingplatform.shared.config; + +import com.codefactory.bookingplatform.auth.api.AuthController; +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import com.codefactory.bookingplatform.identity.api.RegistrationController; +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.RegistrationOutcome; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.shared.observability.TraceIdFilter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.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.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The security filter chain itself, exercised end to end through MockMvc with + * the real {@link SecurityConfig}: which endpoints are public, what an + * unauthenticated call gets back, and how the JWT is turned into authorities. + */ +@WebMvcTest(controllers = {AuthController.class, RegistrationController.class}) +@Import({SecurityConfig.class, SupabaseJwtAuthConverter.class}) +@EnableConfigurationProperties(SecurityProperties.class) +@TestPropertySource(properties = { + "app.security.jwt-issuer=http://localhost:54321/auth/v1", + "app.security.jwks-uri=http://localhost:54321/auth/v1/.well-known/jwks.json"}) +class SecurityConfigWebTest { + + private static final String SUBJECT = "11111111-2222-3333-4444-555555555555"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @MockitoBean + private LoginUseCase loginUseCase; + + @MockitoBean + private LogoutUseCase logoutUseCase; + + @MockitoBean + private PasswordRecoveryUseCase passwordRecoveryUseCase; + + @MockitoBean + private PasswordResetUseCase passwordResetUseCase; + + @MockitoBean + private RegisterClientUseCase registerClientUseCase; + + @MockitoBean + private ResendVerificationUseCase resendVerificationUseCase; + + @MockitoBean + private ConfirmEmailUseCase confirmEmailUseCase; + + private void stubToken(String tokenValue, Map extraClaims) { + Jwt.Builder builder = Jwt.withTokenValue(tokenValue) + .header("alg", "ES256") + .subject(SUBJECT) + .claim("email", "ana.perez@example.com") + .issuedAt(Instant.now().minusSeconds(60)) + .expiresAt(Instant.now().plusSeconds(3600)); + extraClaims.forEach(builder::claim); + when(jwtDecoder.decode(tokenValue)).thenReturn(builder.build()); + } + + @ParameterizedTest(name = "{1} {0} is public and is not answered 401") + @CsvSource({ + "/api/v1/auth/login, POST", + "/api/v1/auth/password-recovery-requests, POST", + "/api/v1/auth/password-resets, POST", + "/api/v1/registrations, POST", + "/api/v1/registrations/verification-resends, POST", + "/api/v1/registrations/email-verifications, POST"}) + @DisplayName("The declared public endpoints are reachable without a token") + void publicEndpointsDoNotRequireAToken(String path, String method) throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("a", "r", "bearer", 3600L)); + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(UUID.fromString(SUBJECT), "ana.perez@example.com", + ClientStatus.PENDING_VERIFICATION)); + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(UUID.fromString(SUBJECT), "ana.perez@example.com", + ClientStatus.ACTIVE)); + + mockMvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + @DisplayName("CSRF is disabled, so a POST without a CSRF token is not answered 403") + void csrfIsDisabledForStatelessApiCalls() throws Exception { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("a", "r", "bearer", 3600L)); + + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\": \"ana.perez@example.com\", \"password\": \"Str0ng!Pass\"}")) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "{0} requires authentication and is answered 401") + @ValueSource(strings = {"/api/v1/auth/me"}) + @DisplayName("A protected endpoint without a token is answered 401 AUTH_REQUIRED") + void protectedEndpointWithoutTokenIs401(String path) throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("POST /logout without a token is answered 401, not 204") + void logoutWithoutTokenIs401() throws Exception { + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + @Test + @DisplayName("The 401 answer is a ProblemDetail pointing at the requested path") + void unauthorizedAnswerIsAProblemDetail() throws Exception { + mockMvc.perform(get("/api/v1/auth/me")) + .andExpect(jsonPath("$.instance").value("/api/v1/auth/me")) + .andExpect(jsonPath("$.title").value("Unauthorized")) + .andExpect(jsonPath("$.type") + .value("https://bookingplatform.codefactory.com/errors/auth_required")); + } + + @Test + @DisplayName("An unauthenticated call still gets a trace id header it can quote in a ticket") + void unauthorizedAnswerCarriesTheTraceIdHeader() throws Exception { + mockMvc.perform(get("/api/v1/auth/me")) + .andExpect(header().exists(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("An unparsable bearer token is answered 401, never 500") + void invalidTokenIs401() throws Exception { + when(jwtDecoder.decode("garbage")).thenThrow(new BadJwtException("malformed")); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer garbage")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("A valid token reaches the endpoint and the role comes from app_metadata") + void validTokenIsAcceptedAndRoleComesFromAppMetadata() throws Exception { + stubToken("valid-token", Map.of("app_metadata", Map.of("role", "client"))); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer valid-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").value("CLIENT")); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: a role planted in user_metadata grants no role at all") + void roleInUserMetadataIsIgnoredEndToEnd() throws Exception { + Map claims = new HashMap<>(); + claims.put("user_metadata", Map.of("role", "admin")); + stubToken("tampered-token", claims); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer tampered-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").doesNotExist()); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: app_metadata wins over a tampered user_metadata") + void appMetadataWinsEndToEnd() throws Exception { + Map claims = new HashMap<>(); + claims.put("app_metadata", Map.of("role", "client")); + claims.put("user_metadata", Map.of("role", "admin")); + stubToken("mixed-token", claims); + + mockMvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer mixed-token")) + .andExpect(jsonPath("$.role").value("CLIENT")); + } + + @Test + @DisplayName("An authenticated logout goes through and answers 204") + void authenticatedLogoutAnswers204() throws Exception { + stubToken("valid-token", Map.of("app_metadata", Map.of("role", "client"))); + + mockMvc.perform(post("/api/v1/auth/logout").header(HttpHeaders.AUTHORIZATION, "Bearer valid-token")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("An unmapped path still requires authentication, so nothing is public by accident") + void unmappedPathsAreNotPublic() throws Exception { + mockMvc.perform(get("/api/v1/internal/whatever")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java new file mode 100644 index 0000000..6e22e9f --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java @@ -0,0 +1,226 @@ +package com.codefactory.bookingplatform.shared.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Security rule under test: the application role is taken from the + * server-managed app_metadata claim only. Anything else, including the + * client-editable user_metadata claim, must grant nothing. + * + * White box: the converter has three decision points (claim is a Map, role is a + * String, role is not blank); each one is exercised on both outcomes. + */ +class SupabaseJwtAuthConverterTest { + + private final SupabaseJwtAuthConverter converter = new SupabaseJwtAuthConverter(); + + private static Jwt jwtWithClaims(Map claims) { + Jwt.Builder builder = Jwt.withTokenValue("token") + .header("alg", "ES256") + .subject("11111111-2222-3333-4444-555555555555") + .issuedAt(Instant.parse("2026-09-22T10:00:00Z")) + .expiresAt(Instant.parse("2026-09-22T11:00:00Z")); + claims.forEach(builder::claim); + return builder.build(); + } + + private static Jwt jwtWithAppMetadata(Object appMetadata) { + Map claims = new HashMap<>(); + claims.put("app_metadata", appMetadata); + return jwtWithClaims(claims); + } + + private static List authorityNames(Collection authorities) { + return authorities.stream().map(GrantedAuthority::getAuthority).toList(); + } + + @ParameterizedTest(name = "app_metadata.role = [{0}] grants [{1}]") + @CsvSource({ + "client, ROLE_CLIENT", + "CLIENT, ROLE_CLIENT", + "Provider, ROLE_PROVIDER", + "aDmIn, ROLE_ADMIN", + "a, ROLE_A"}) + @DisplayName("A role in app_metadata becomes ROLE_ plus its upper case name") + void roleFromAppMetadataIsUpperCasedAndPrefixed(String role, String expectedAuthority) { + Collection authorities = converter.convert(jwtWithAppMetadata(Map.of("role", role))); + + assertEquals(List.of(expectedAuthority), authorityNames(authorities)); + } + + @Test + @DisplayName("Exactly one authority is granted, never a duplicate") + void grantsASingleAuthority() { + assertEquals(1, converter.convert(jwtWithAppMetadata(Map.of("role", "client"))).size()); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: a role planted in user_metadata is ignored") + void roleInUserMetadataIsIgnored() { + Map claims = new HashMap<>(); + claims.put("user_metadata", Map.of("role", "admin")); + + Collection authorities = converter.convert(jwtWithClaims(claims)); + + assertEquals(List.of(), authorityNames(authorities)); + } + + @Test + @DisplayName("PRIVILEGE ESCALATION GUARD: user_metadata never overrides the app_metadata role") + void userMetadataDoesNotOverrideAppMetadata() { + Map claims = new HashMap<>(); + claims.put("app_metadata", Map.of("role", "client")); + claims.put("user_metadata", Map.of("role", "admin")); + + Collection authorities = converter.convert(jwtWithClaims(claims)); + + assertEquals(List.of("ROLE_CLIENT"), authorityNames(authorities)); + } + + @Test + @DisplayName("An absent app_metadata claim grants nothing") + void missingAppMetadataGrantsNothing() { + Map claims = new HashMap<>(); + claims.put("email", "ana@example.com"); + + assertTrue(converter.convert(jwtWithClaims(claims)).isEmpty()); + } + + @Test + @DisplayName("An app_metadata claim that is not a map grants nothing") + void nonMapAppMetadataGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata("role=admin")).isEmpty()); + } + + @Test + @DisplayName("An app_metadata list grants nothing") + void listAppMetadataGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata(List.of("admin"))).isEmpty()); + } + + @Test + @DisplayName("An app_metadata map without a role key grants nothing") + void appMetadataWithoutRoleGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata(Map.of("provider", "email"))).isEmpty()); + } + + @Test + @DisplayName("An empty app_metadata map grants nothing") + void emptyAppMetadataGrantsNothing() { + assertTrue(converter.convert(jwtWithAppMetadata(Map.of())).isEmpty()); + } + + @ParameterizedTest(name = "a blank role [{0}] grants nothing") + @ValueSource(strings = {"", " ", " ", "\t", "\n"}) + void blankRoleGrantsNothing(String role) { + Map metadata = new HashMap<>(); + metadata.put("role", role); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A null role grants nothing") + void nullRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", null); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A numeric role is not a String and grants nothing") + void numericRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", 42); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A role given as a list is not a String and grants nothing") + void listRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", List.of("admin")); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("A boolean role is not a String and grants nothing") + void booleanRoleGrantsNothing() { + Map metadata = new HashMap<>(); + metadata.put("role", Boolean.TRUE); + + assertTrue(converter.convert(jwtWithAppMetadata(metadata)).isEmpty()); + } + + @Test + @DisplayName("The returned collection is immutable, so no caller can add an authority") + void returnedAuthoritiesAreImmutable() { + Collection authorities = converter.convert(jwtWithAppMetadata(Map.of("role", "client"))); + + org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class, + () -> authorities.add(() -> "ROLE_ADMIN")); + } + + /** The framework adds its own factor authorities (FACTOR_BEARER); only the roles matter here. */ + private static List roleNames(Collection a) { + return a.stream().map(org.springframework.security.core.GrantedAuthority::getAuthority) + .filter(name -> name.startsWith("ROLE_")) + .toList(); + } + + @Test + @DisplayName("toAuthenticationConverter wires the authorities converter into the Spring Security token") + void authenticationConverterUsesTheAuthoritiesConverter() { + JwtAuthenticationConverter authenticationConverter = + SupabaseJwtAuthConverter.toAuthenticationConverter(converter); + + JwtAuthenticationToken token = (JwtAuthenticationToken) authenticationConverter + .convert(jwtWithAppMetadata(Map.of("role", "admin"))); + + assertNotNull(token); + assertEquals(List.of("ROLE_ADMIN"), roleNames(token.getAuthorities())); + } + + @Test + @DisplayName("toAuthenticationConverter grants no role when the role sits in user_metadata") + void authenticationConverterIgnoresUserMetadata() { + Map claims = new HashMap<>(); + claims.put("user_metadata", Map.of("role", "admin")); + JwtAuthenticationConverter authenticationConverter = + SupabaseJwtAuthConverter.toAuthenticationConverter(converter); + + JwtAuthenticationToken token = (JwtAuthenticationToken) authenticationConverter.convert(jwtWithClaims(claims)); + + assertNotNull(token); + assertEquals(List.of(), roleNames(token.getAuthorities())); + } + + @Test + @DisplayName("The public constants keep the contract the rest of the code relies on") + void constantsAreStable() { + assertEquals("app_metadata", SupabaseJwtAuthConverter.APP_METADATA_CLAIM); + assertEquals("role", SupabaseJwtAuthConverter.ROLE_KEY); + assertEquals("ROLE_", SupabaseJwtAuthConverter.ROLE_PREFIX); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/observability/TraceIdFilterTest.java b/src/test/java/com/codefactory/bookingplatform/shared/observability/TraceIdFilterTest.java new file mode 100644 index 0000000..7c27ec8 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/observability/TraceIdFilterTest.java @@ -0,0 +1,232 @@ +package com.codefactory.bookingplatform.shared.observability; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Pure unit tests for the trace id propagation filter. The test lives in the + * filter package so that doFilterInternal can be driven directly, without a + * servlet container. + */ +class TraceIdFilterTest { + + private final TraceIdFilter filter = new TraceIdFilter(); + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private FilterChain chain; + + @BeforeEach + void setUp() { + MDC.clear(); + request = new MockHttpServletRequest("GET", "/api/v1/auth/me"); + response = new MockHttpServletResponse(); + chain = mock(FilterChain.class); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("An incoming X-Trace-Id is honoured and echoed back untouched") + void incomingTraceIdIsHonoured() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "trace-from-the-gateway"); + + filter.doFilterInternal(request, response, chain); + + assertEquals("trace-from-the-gateway", response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("The incoming trace id is the one visible in the MDC while the chain runs") + void incomingTraceIdIsVisibleInMdcDuringTheChain() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "trace-from-the-gateway"); + AtomicReference seenInsideChain = new AtomicReference<>(); + FilterChain capturing = (req, res) -> seenInsideChain.set(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + + filter.doFilterInternal(request, response, capturing); + + assertEquals("trace-from-the-gateway", seenInsideChain.get()); + } + + @ParameterizedTest(name = "a missing, empty or blank incoming header [{0}] is replaced by a generated id") + @NullSource + @ValueSource(strings = {"", " ", "\t"}) + void missingOrBlankHeaderIsReplaced(String incoming) throws Exception { + if (incoming != null) { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, incoming); + } + + filter.doFilterInternal(request, response, chain); + + String generated = response.getHeader(TraceIdFilter.TRACE_ID_HEADER); + assertDoesNotThrow(() -> UUID.fromString(generated)); + } + + @Test + @DisplayName("The response always carries the trace id header, even when none came in") + void responseAlwaysCarriesTheHeader() throws Exception { + filter.doFilterInternal(request, response, chain); + + assertNotNull(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("Two requests without an incoming header get two different generated ids") + void generatedIdsAreUnique() throws Exception { + filter.doFilterInternal(request, response, chain); + String first = response.getHeader(TraceIdFilter.TRACE_ID_HEADER); + + MockHttpServletResponse secondResponse = new MockHttpServletResponse(); + filter.doFilterInternal(new MockHttpServletRequest("GET", "/api/v1/auth/me"), secondResponse, chain); + String second = secondResponse.getHeader(TraceIdFilter.TRACE_ID_HEADER); + + org.junit.jupiter.api.Assertions.assertNotEquals(first, second); + } + + @Test + @DisplayName("The response header and the MDC value seen by the chain are the same id") + void headerAndMdcValueMatch() throws Exception { + AtomicReference seenInsideChain = new AtomicReference<>(); + FilterChain capturing = (req, res) -> seenInsideChain.set(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + + filter.doFilterInternal(request, response, capturing); + + assertEquals(seenInsideChain.get(), response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("The chain is invoked exactly once with the very same request and response") + void chainIsInvokedOnce() throws Exception { + filter.doFilterInternal(request, response, chain); + + verify(chain).doFilter(request, response); + } + + @Test + @DisplayName("The MDC is cleaned once the chain returns normally") + void mdcIsCleanedAfterASuccessfulChain() throws Exception { + filter.doFilterInternal(request, response, chain); + + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("CONTEXT LEAK GUARD: the MDC is cleaned even when the chain throws a ServletException") + void mdcIsCleanedWhenTheChainThrowsServletException() throws Exception { + doThrow(new ServletException("boom")).when(chain).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(ServletException.class, () -> filter.doFilterInternal(request, response, chain)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("CONTEXT LEAK GUARD: the MDC is cleaned even when the chain throws an IOException") + void mdcIsCleanedWhenTheChainThrowsIoException() throws Exception { + doThrow(new IOException("socket closed")).when(chain) + .doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(IOException.class, () -> filter.doFilterInternal(request, response, chain)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("CONTEXT LEAK GUARD: the MDC is cleaned even when the chain throws an unchecked exception") + void mdcIsCleanedWhenTheChainThrowsRuntimeException() throws Exception { + doThrow(new IllegalStateException("unexpected")).when(chain) + .doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(IllegalStateException.class, () -> filter.doFilterInternal(request, response, chain)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("A failing request still answers with the trace id header, so the caller can report it") + void headerIsSetBeforeTheChainRuns() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "trace-before-failure"); + doThrow(new IllegalStateException("unexpected")).when(chain) + .doFilter(any(ServletRequest.class), any(ServletResponse.class)); + + assertThrows(IllegalStateException.class, () -> filter.doFilterInternal(request, response, chain)); + assertEquals("trace-before-failure", response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("A stale trace id left over from a previous request is overwritten, never reused") + void staleMdcValueIsOverwritten() throws Exception { + MDC.put(TraceIdFilter.TRACE_ID_MDC_KEY, "stale-value-from-a-previous-request"); + AtomicReference seenInsideChain = new AtomicReference<>(); + FilterChain capturing = (req, res) -> seenInsideChain.set(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "fresh-value"); + + filter.doFilterInternal(request, response, capturing); + + assertEquals("fresh-value", seenInsideChain.get()); + } + + @Test + @DisplayName("Going through the public doFilter entry point behaves like doFilterInternal") + void publicDoFilterEntryPointWorks() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "through-the-front-door"); + + filter.doFilter(request, response, chain); + + assertEquals("through-the-front-door", response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + assertNull(MDC.get(TraceIdFilter.TRACE_ID_MDC_KEY)); + } + + @Test + @DisplayName("Only one trace id header value is written, never a second appended one") + void headerIsSetNotAdded() throws Exception { + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "single-value"); + + filter.doFilterInternal(request, response, chain); + + assertEquals(1, response.getHeaders(TraceIdFilter.TRACE_ID_HEADER).size()); + } + + @Test + @DisplayName("The published constants keep the contract the logging pattern relies on") + void constantsAreStable() { + assertEquals("X-Trace-Id", TraceIdFilter.TRACE_ID_HEADER); + assertEquals("traceId", TraceIdFilter.TRACE_ID_MDC_KEY); + } + + @Test + @DisplayName("A generated trace id is a random UUID, not a predictable counter") + void generatedTraceIdIsAUuid() throws Exception { + filter.doFilterInternal(request, response, chain); + + String generated = response.getHeader(TraceIdFilter.TRACE_ID_HEADER); + assertNotNull(generated); + assertTrue(generated.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/support/BeanValidationSupport.java b/src/test/java/com/codefactory/bookingplatform/support/BeanValidationSupport.java new file mode 100644 index 0000000..efaa9fe --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/support/BeanValidationSupport.java @@ -0,0 +1,62 @@ +package com.codefactory.bookingplatform.support; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Shared Jakarta Validator for the DTO boundary tests. Building the factory is + * expensive, so it is created once and reused by every DTO test class. + */ +public final class BeanValidationSupport { + + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); + private static final Validator VALIDATOR = FACTORY.getValidator(); + + private BeanValidationSupport() { + } + + public static Validator validator() { + return VALIDATOR; + } + + /** Every violation message raised on {@code property}, empty when the field is accepted. */ + public static Set messagesFor(T bean, String property) { + return VALIDATOR.validate(bean).stream() + .filter(v -> property.equals(v.getPropertyPath().toString())) + .map(ConstraintViolation::getMessage) + .collect(Collectors.toSet()); + } + + /** Names of every property that raised at least one violation. */ + public static Set invalidProperties(T bean) { + return VALIDATOR.validate(bean).stream() + .map(v -> v.getPropertyPath().toString()) + .collect(Collectors.toSet()); + } + + public static String repeat(char character, int length) { + return String.valueOf(character).repeat(length); + } + + /** + * A syntactically valid email of exactly {@code totalLength} characters. + * The local part is capped at the 64 characters RFC 5321 allows and the + * domain is split into labels short enough to stay valid, so the only + * constraint the result can ever trip is the length one. + * Valid for totalLength between 131 and 190. + */ + public static String emailOfLength(int totalLength) { + int localPart = 64; + int secondLabel = totalLength - 130; + return repeat('a', localPart) + "@" + repeat('b', 60) + "." + repeat('c', secondLabel) + ".com"; + } + + public static String digits(int length) { + return repeat('7', length); + } +} From c76a2f8ec8f8cdd87237c8fc6291ee7d842477dc Mon Sep 17 00:00:00 2001 From: Anderson Herrera <43342146+andersonhg19@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:12:09 -0500 Subject: [PATCH 06/12] test: drop 156 cases that tested the language, not the solution The suite had grown to 1046 cases for 156 branches and 2102 lines of production code. That ratio does not hold up, and the excess was not spread evenly: it sat almost entirely on enums and records. ErrorCodeTest carried 85 cases for an enum of sixteen values, most of them asserting that valueOf round-trips and that name() is not null, which tests the JVM. It keeps the sixteen-row decision table that pins each code to its HTTP status, because that one is a contract consumers depend on: change DUPLICATE_EMAIL from 409 and a client breaks. The tests for AppRole, AuthTokens, ConfirmedUser, UpstreamAuthError, UpstreamAuthException, ClientStatus, NotificationChannel, RegistrationOutcome and RegisterClientCommand are gone entirely. They asserted over compiler generated equals, hashCode and toString, and over enum constants. The measurement makes the case on its own: 156 cases removed and coverage did not move. Instructions, branches, lines and methods all stay at 100% over the same 52 classes. Those cases were covering nothing that other tests were not already covering, so all they added was execution time and maintenance. 890 unit tests plus 21 integration, green. --- .../auth/domain/model/AppRoleTest.java | 48 ----------- .../auth/domain/model/AuthTokensTest.java | 76 ----------------- .../auth/domain/model/ConfirmedUserTest.java | 75 ----------------- .../domain/model/UpstreamAuthErrorTest.java | 57 ------------- .../model/UpstreamAuthExceptionTest.java | 83 ------------------- .../RegisterClientCommandTest.java | 80 ------------------ .../application/RegistrationOutcomeTest.java | 72 ---------------- .../domain/model/ClientStatusTest.java | 51 ------------ .../domain/model/NotificationChannelTest.java | 45 ---------- .../shared/error/ErrorCodeTest.java | 74 ++--------------- 10 files changed, 5 insertions(+), 656 deletions(-) delete mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java delete mode 100644 src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java deleted file mode 100644 index e4051ef..0000000 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AppRoleTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.codefactory.bookingplatform.auth.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -import java.util.List; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * The application roles are part of the authorization contract: their names travel inside the JWT - * claims and inside the database, so both the set of roles and their spelling are frozen behaviour. - */ -class AppRoleTest { - - @Test - @DisplayName("The platform recognises exactly three roles, in the documented order") - void exposesTheThreeApplicationRoles() { - assertIterableEquals(List.of(AppRole.CLIENT, AppRole.PROVIDER, AppRole.ADMIN), - Stream.of(AppRole.values()).toList()); - } - - @ParameterizedTest(name = "{0} round-trips through its name") - @EnumSource(AppRole.class) - @DisplayName("Every role can be resolved back from its own name, which is how it is persisted and read from the token") - void everyRoleRoundTripsThroughItsName(AppRole role) { - assertEquals(role, AppRole.valueOf(role.name())); - } - - @ParameterizedTest(name = "{0} has a stable ordinal and name") - @EnumSource(AppRole.class) - @DisplayName("Every role exposes a non-null name") - void everyRoleHasAName(AppRole role) { - assertNotNull(role.name()); - } - - @Test - @DisplayName("An unknown role name is rejected instead of silently resolving to a default") - void unknownRoleNameIsRejected() { - assertThrows(IllegalArgumentException.class, () -> AppRole.valueOf("SUPERUSER")); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java deleted file mode 100644 index 0586722..0000000 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/AuthTokensTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.codefactory.bookingplatform.auth.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Value object returned by a successful authentication. Being a record it is immutable and compared - * by value, which is what lets the application layer pass it around safely. - */ -class AuthTokensTest { - - private static final AuthTokens TOKENS = new AuthTokens("access-123", "refresh-456", "bearer", 3600L); - - @Test - @DisplayName("The record exposes every token field exactly as it was built") - void exposesEveryField() { - assertEquals("access-123", TOKENS.accessToken()); - assertEquals("refresh-456", TOKENS.refreshToken()); - assertEquals("bearer", TOKENS.tokenType()); - assertEquals(3600L, TOKENS.expiresIn()); - } - - @Test - @DisplayName("Two token sets with the same values are equal, because the record is compared by value") - void equalValuesAreEqual() { - assertEquals(TOKENS, new AuthTokens("access-123", "refresh-456", "bearer", 3600L)); - } - - @Test - @DisplayName("Equal token sets share the same hash code, so they can be used as map keys") - void equalValuesShareHashCode() { - assertEquals(TOKENS.hashCode(), new AuthTokens("access-123", "refresh-456", "bearer", 3600L).hashCode()); - } - - @Test - @DisplayName("A different access token makes the value object different") - void differentAccessTokenIsNotEqual() { - assertNotEquals(TOKENS, new AuthTokens("other", "refresh-456", "bearer", 3600L)); - } - - @Test - @DisplayName("A different expiry makes the value object different") - void differentExpiryIsNotEqual() { - assertNotEquals(TOKENS, new AuthTokens("access-123", "refresh-456", "bearer", 60L)); - } - - @Test - @DisplayName("The value object is never equal to null or to another type") - void notEqualToNullOrOtherTypes() { - assertNotEquals(null, TOKENS); - assertNotEquals("access-123", TOKENS); - } - - @Test - @DisplayName("The textual form names the record and its fields, which is what ends up in the logs") - void toStringDescribesTheRecord() { - String text = TOKENS.toString(); - assertTrue(text.contains("AuthTokens"), () -> "Expected the record name in " + text); - assertTrue(text.contains("accessToken"), () -> "Expected the field names in " + text); - } - - @Test - @DisplayName("Missing tokens are accepted as null so a partial upstream response can still be modelled") - void nullFieldsAreAccepted() { - AuthTokens partial = new AuthTokens(null, null, null, 0L); - assertNull(partial.accessToken()); - assertNull(partial.refreshToken()); - assertNull(partial.tokenType()); - assertEquals(0L, partial.expiresIn()); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java deleted file mode 100644 index 9faa37c..0000000 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/ConfirmedUserTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.codefactory.bookingplatform.auth.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Value object describing the user whose email has just been confirmed. It is the pair the identity - * module needs to link the platform account with the identity provider account. - */ -class ConfirmedUserTest { - - private static final UUID USER_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); - private static final ConfirmedUser USER = new ConfirmedUser(USER_ID, "ana@example.com"); - - @Test - @DisplayName("The record exposes the identifier and the email exactly as they were built") - void exposesEveryField() { - assertEquals(USER_ID, USER.userId()); - assertEquals("ana@example.com", USER.email()); - } - - @Test - @DisplayName("Two confirmed users with the same identifier and email are equal") - void equalValuesAreEqual() { - assertEquals(USER, new ConfirmedUser(USER_ID, "ana@example.com")); - } - - @Test - @DisplayName("Equal confirmed users share the same hash code") - void equalValuesShareHashCode() { - assertEquals(USER.hashCode(), new ConfirmedUser(USER_ID, "ana@example.com").hashCode()); - } - - @Test - @DisplayName("A different identifier makes the value object different even when the email matches") - void differentIdentifierIsNotEqual() { - assertNotEquals(USER, new ConfirmedUser(UUID.randomUUID(), "ana@example.com")); - } - - @Test - @DisplayName("Email comparison is case sensitive, so the record does not normalise it") - void emailComparisonIsCaseSensitive() { - assertNotEquals(USER, new ConfirmedUser(USER_ID, "ANA@example.com")); - } - - @Test - @DisplayName("The value object is never equal to null or to another type") - void notEqualToNullOrOtherTypes() { - assertNotEquals(null, USER); - assertNotEquals("ana@example.com", USER); - } - - @Test - @DisplayName("The textual form names the record and its fields") - void toStringDescribesTheRecord() { - String text = USER.toString(); - assertTrue(text.contains("ConfirmedUser"), () -> "Expected the record name in " + text); - assertTrue(text.contains("email"), () -> "Expected the field names in " + text); - } - - @Test - @DisplayName("Null components are accepted, so the record does not validate on its own") - void nullComponentsAreAccepted() { - ConfirmedUser empty = new ConfirmedUser(null, null); - assertNull(empty.userId()); - assertNull(empty.email()); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java deleted file mode 100644 index bfbdc9f..0000000 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthErrorTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.codefactory.bookingplatform.auth.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -import java.util.List; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * Catalogue of failures the identity provider can report. The adapter translates the upstream - * response into one of these, so the set is the contract between the Supabase adapter and the - * domain. - */ -class UpstreamAuthErrorTest { - - @Test - @DisplayName("The catalogue covers the eight failures the identity provider can report") - void catalogueHasTheEightKnownFailures() { - assertIterableEquals( - List.of(UpstreamAuthError.INVALID_CREDENTIALS, - UpstreamAuthError.EMAIL_NOT_CONFIRMED, - UpstreamAuthError.USER_ALREADY_EXISTS, - UpstreamAuthError.USER_NOT_FOUND, - UpstreamAuthError.TOKEN_INVALID, - UpstreamAuthError.TOKEN_EXPIRED, - UpstreamAuthError.RATE_LIMITED, - UpstreamAuthError.UNAVAILABLE), - Stream.of(UpstreamAuthError.values()).toList()); - } - - @ParameterizedTest(name = "{0} round-trips through its name") - @EnumSource(UpstreamAuthError.class) - @DisplayName("Every upstream failure can be resolved back from its own name") - void everyErrorRoundTripsThroughItsName(UpstreamAuthError error) { - assertEquals(error, UpstreamAuthError.valueOf(error.name())); - } - - @ParameterizedTest(name = "{0} has a name") - @EnumSource(UpstreamAuthError.class) - @DisplayName("Every upstream failure exposes a non-null name") - void everyErrorHasAName(UpstreamAuthError error) { - assertNotNull(error.name()); - } - - @Test - @DisplayName("An upstream failure the domain does not know about is rejected instead of being mapped blindly") - void unknownErrorNameIsRejected() { - assertThrows(IllegalArgumentException.class, () -> UpstreamAuthError.valueOf("QUOTA_EXCEEDED")); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java deleted file mode 100644 index 50bcd3a..0000000 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.codefactory.bookingplatform.auth.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -import java.io.IOException; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * Failure raised when the identity provider rejects or cannot serve a request. It carries the - * classified {@link UpstreamAuthError} so the application layer can map it to an HTTP status - * without parsing the upstream message. - */ -class UpstreamAuthExceptionTest { - - @Test - @DisplayName("The two-argument constructor keeps the classified error and the message") - void twoArgumentConstructorKeepsErrorAndMessage() { - UpstreamAuthException ex = new UpstreamAuthException(UpstreamAuthError.INVALID_CREDENTIALS, "bad password"); - assertEquals(UpstreamAuthError.INVALID_CREDENTIALS, ex.error()); - assertEquals("bad password", ex.getMessage()); - } - - @Test - @DisplayName("The two-argument constructor leaves the cause unset") - void twoArgumentConstructorHasNoCause() { - UpstreamAuthException ex = new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "provider down"); - assertNull(ex.getCause()); - } - - @Test - @DisplayName("The three-argument constructor keeps the original failure as the cause, so the stack trace survives") - void threeArgumentConstructorKeepsTheCause() { - IOException cause = new IOException("connection reset"); - UpstreamAuthException ex = new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "provider down", cause); - assertSame(cause, ex.getCause()); - } - - @Test - @DisplayName("The three-argument constructor keeps the classified error and the message as well") - void threeArgumentConstructorKeepsErrorAndMessage() { - UpstreamAuthException ex = new UpstreamAuthException( - UpstreamAuthError.RATE_LIMITED, "too many calls", new IOException("429")); - assertEquals(UpstreamAuthError.RATE_LIMITED, ex.error()); - assertEquals("too many calls", ex.getMessage()); - } - - @ParameterizedTest(name = "{0} is preserved by the exception") - @EnumSource(UpstreamAuthError.class) - @DisplayName("Any upstream failure can be carried unchanged by the exception") - void anyErrorIsCarriedUnchanged(UpstreamAuthError error) { - assertEquals(error, new UpstreamAuthException(error, "upstream said no").error()); - } - - @Test - @DisplayName("The exception is unchecked so adapters do not have to declare it") - void exceptionIsUnchecked() { - assertInstanceOf(RuntimeException.class, - new UpstreamAuthException(UpstreamAuthError.TOKEN_EXPIRED, "expired")); - } - - @Test - @DisplayName("The exception can be thrown and caught by its own type") - void exceptionIsThrowable() { - UpstreamAuthException thrown = assertThrows(UpstreamAuthException.class, () -> { - throw new UpstreamAuthException(UpstreamAuthError.TOKEN_INVALID, "malformed token"); - }); - assertEquals(UpstreamAuthError.TOKEN_INVALID, thrown.error()); - } - - @Test - @DisplayName("A null message is accepted and reported back as null rather than as an empty string") - void nullMessageIsPreserved() { - assertNull(new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, null).getMessage()); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java deleted file mode 100644 index bd519b6..0000000 --- a/src/test/java/com/codefactory/bookingplatform/identity/application/RegisterClientCommandTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.codefactory.bookingplatform.identity.application; - -import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Input carrier of the registration use case. - * - * Techniques applied: contract testing of the record (accessors, value - * equality, hashCode consistency) plus a check that the raw password is not - * part of the accidental logging surface we rely on. - */ -class RegisterClientCommandTest { - - private static final LocalDate BIRTH_DATE = LocalDate.of(1995, 4, 10); - - private RegisterClientCommand command() { - return new RegisterClientCommand("Ana Perez", "CC12345678", BIRTH_DATE, - "ana@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"); - } - - @Test - @DisplayName("The command exposes every registration field exactly as received") - void exposesEveryField() { - RegisterClientCommand command = command(); - - assertEquals("Ana Perez", command.fullName()); - assertEquals("CC12345678", command.document()); - assertEquals(BIRTH_DATE, command.birthDate()); - assertEquals("ana@example.com", command.email()); - assertEquals("+573001234567", command.phone()); - assertEquals("Bogota", command.city()); - assertEquals(NotificationChannel.EMAIL, command.notificationChannel()); - assertEquals("Str0ng!Pass", command.password()); - } - - @Test - @DisplayName("Two commands with the same registration data are equal and share the hash") - void valueEquality() { - assertEquals(command(), command()); - assertEquals(command().hashCode(), command().hashCode()); - } - - @Test - @DisplayName("A command that differs in a single field is not equal") - void differentEmailBreaksEquality() { - RegisterClientCommand other = new RegisterClientCommand("Ana Perez", "CC12345678", BIRTH_DATE, - "otra@example.com", "+573001234567", "Bogota", NotificationChannel.EMAIL, "Str0ng!Pass"); - - assertNotEquals(command(), other); - } - - @Test - @DisplayName("The command tolerates optional fields left null so the use case can validate them") - void toleratesNullOptionalFields() { - RegisterClientCommand command = new RegisterClientCommand(null, null, null, null, null, null, null, null); - - assertNull(command.fullName()); - assertNull(command.birthDate()); - assertNull(command.notificationChannel()); - assertEquals(command, new RegisterClientCommand(null, null, null, null, null, null, null, null)); - } - - @Test - @DisplayName("The command rendering keeps the field names used when debugging a registration") - void renderingKeepsFieldNames() { - String rendered = command().toString(); - - assertTrue(rendered.contains("fullName=Ana Perez"), rendered); - assertTrue(rendered.contains("email=ana@example.com"), rendered); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java b/src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java deleted file mode 100644 index 8bd25e7..0000000 --- a/src/test/java/com/codefactory/bookingplatform/identity/application/RegistrationOutcomeTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.codefactory.bookingplatform.identity.application; - -import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Output carrier shared by registration and email confirmation. - * - * Techniques applied: contract testing of the record (accessors, value - * equality, hashCode consistency) and equivalence partitioning over the - * statuses the use cases can return. - */ -class RegistrationOutcomeTest { - - private static final UUID CLIENT_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); - - @Test - @DisplayName("The outcome exposes the client id, email and status returned to the API") - void exposesEveryField() { - RegistrationOutcome outcome = - new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.PENDING_VERIFICATION); - - assertEquals(CLIENT_ID, outcome.clientId()); - assertEquals("ana@example.com", outcome.email()); - assertEquals(ClientStatus.PENDING_VERIFICATION, outcome.status()); - } - - @Test - @DisplayName("Two outcomes describing the same registration are equal and share the hash") - void valueEquality() { - RegistrationOutcome one = new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE); - RegistrationOutcome another = new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE); - - assertEquals(one, another); - assertEquals(one.hashCode(), another.hashCode()); - } - - @Test - @DisplayName("Registration and confirmation outcomes of the same client differ by status") - void differentStatusBreaksEquality() { - RegistrationOutcome registered = - new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.PENDING_VERIFICATION); - RegistrationOutcome confirmed = - new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE); - - assertNotEquals(registered, confirmed); - } - - @ParameterizedTest - @DisplayName("Any client status can be reported back without losing information") - @EnumSource(ClientStatus.class) - void carriesAnyStatus(ClientStatus status) { - assertEquals(status, new RegistrationOutcome(CLIENT_ID, "ana@example.com", status).status()); - } - - @Test - @DisplayName("The outcome rendering keeps the client id used when tracing a registration") - void renderingKeepsClientId() { - String rendered = new RegistrationOutcome(CLIENT_ID, "ana@example.com", ClientStatus.ACTIVE).toString(); - - assertTrue(rendered.contains(CLIENT_ID.toString()), rendered); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java deleted file mode 100644 index 32eb51d..0000000 --- a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/ClientStatusTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.codefactory.bookingplatform.identity.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.util.Arrays; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * The status vocabulary is persisted as text and travels in the API contract, - * so the set of constants and their exact spelling are part of the contract. - * - * Techniques applied: equivalence partitioning over the enum domain - * (valid names vs. unknown names) and contract pinning of the ordering. - */ -class ClientStatusTest { - - @Test - @DisplayName("The client lifecycle declares exactly three statuses in registration order") - void declaresExactlyThreeStatuses() { - assertEquals( - List.of(ClientStatus.PENDING_VERIFICATION, ClientStatus.ACTIVE, ClientStatus.SUSPENDED), - Arrays.asList(ClientStatus.values())); - } - - @ParameterizedTest - @DisplayName("Every status round-trips through its persisted name") - @EnumSource(ClientStatus.class) - void statusRoundTripsThroughItsName(ClientStatus status) { - assertEquals(status, ClientStatus.valueOf(status.name())); - } - - @ParameterizedTest - @DisplayName("An unknown or wrongly cased status name is rejected instead of silently mapped") - @ValueSource(strings = {"PENDING", "active", "DELETED", "", " ACTIVE"}) - void unknownStatusNameIsRejected(String name) { - assertThrows(IllegalArgumentException.class, () -> ClientStatus.valueOf(name)); - } - - @Test - @DisplayName("PENDING_VERIFICATION is the first status so it is the natural default for a new client") - void pendingVerificationIsTheFirstStatus() { - assertEquals(0, ClientStatus.PENDING_VERIFICATION.ordinal()); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java b/src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java deleted file mode 100644 index 7b9ee12..0000000 --- a/src/test/java/com/codefactory/bookingplatform/identity/domain/model/NotificationChannelTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.codefactory.bookingplatform.identity.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.util.Arrays; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * The notification channel chosen at registration is persisted as text and is - * part of the registration request contract. - * - * Techniques applied: equivalence partitioning over the enum domain - * (valid names vs. unknown names) and contract pinning of the constant set. - */ -class NotificationChannelTest { - - @Test - @DisplayName("Registration offers exactly three notification channels") - void declaresExactlyThreeChannels() { - assertEquals( - List.of(NotificationChannel.EMAIL, NotificationChannel.SMS, NotificationChannel.WHATSAPP), - Arrays.asList(NotificationChannel.values())); - } - - @ParameterizedTest - @DisplayName("Every channel round-trips through its persisted name") - @EnumSource(NotificationChannel.class) - void channelRoundTripsThroughItsName(NotificationChannel channel) { - assertEquals(channel, NotificationChannel.valueOf(channel.name())); - } - - @ParameterizedTest - @DisplayName("An unsupported or wrongly cased channel name is rejected instead of silently mapped") - @ValueSource(strings = {"PUSH", "email", "Whatsapp", "", "TELEGRAM"}) - void unsupportedChannelNameIsRejected(String name) { - assertThrows(IllegalArgumentException.class, () -> NotificationChannel.valueOf(name)); - } -} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java index 4b3e861..9d5330f 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/ErrorCodeTest.java @@ -4,51 +4,19 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; import org.springframework.http.HttpStatus; -import java.util.Arrays; -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; /** - * The error catalogue is the public API contract: every code maps to one HTTP status and one - * default message, and both travel to the client inside the ProblemDetail body. + * El catálogo de errores es contrato público: cada código viaja al cliente dentro del + * ProblemDetail y lleva asociado un estado HTTP del que dependen los consumidores de la API. * - *

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 Map verify(String tokenHash, String type, String passwor .body(Map.class); return response != null ? response : Map.of(); } catch (RestClientResponseException ex) { - throw mapError(ex, "verify"); + throw mapError(ex, VERIFY_CONTEXT); } catch (ResourceAccessException ex) { throw unavailable(ex); } @@ -222,6 +228,35 @@ private Object requireField(Map response, String field) { return response.get(field); } + /** + * A malformed id is an unusable provider answer, exactly like a missing one: + * the raw {@link IllegalArgumentException} from {@code UUID.fromString} would + * escape the adapter and surface as a 500 instead of an upstream error. + */ + private UUID requireUuid(Map response, String field) { + String raw = String.valueOf(requireField(response, field)); + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + throw new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, + "Unexpected identity provider response, field is not a valid UUID: " + field, ex); + } + } + + /** + * {@code expires_in} is optional (defaults to one hour), but a present value that + * is not a whole number — {@code "never"}, or the perfectly valid JSON number + * {@code 3600.0} — must not escape as a raw {@link NumberFormatException}. + */ + private long requireExpiresIn(Map response) { + try { + return Long.parseLong(String.valueOf(response.getOrDefault("expires_in", "3600"))); + } catch (NumberFormatException ex) { + throw new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, + "Unexpected identity provider response, field is not a number: expires_in", ex); + } + } + private UpstreamAuthException unavailable(Exception cause) { log.error("Identity provider unreachable: {}", cause.getMessage()); return new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, @@ -242,20 +277,25 @@ private UpstreamAuthException mapError(RestClientResponseException ex, String co if (body.contains("already exists") || body.contains("user_exists") || body.contains("email_exists")) { return new UpstreamAuthException(UpstreamAuthError.USER_ALREADY_EXISTS, "User already exists"); } - if (body.contains("not found") || status == 404) { - return new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "User not found"); - } + // GoTrue answers 404 to an expired or already consumed OTP, so "expired" has to + // be checked before the 404 rule: otherwise a stale verification link is reported + // as USER_NOT_FOUND and the user reads "usuario no encontrado". if (body.contains("expired")) { return new UpstreamAuthException(UpstreamAuthError.TOKEN_EXPIRED, "Token has expired"); } + // A bare 404 from /verify is about the one-time token, never about a user; it is + // classified below by the verify branch. Every other endpoint keeps the old rule. + if (body.contains("not found") || (status == 404 && !VERIFY_CONTEXT.equals(context))) { + return new UpstreamAuthException(UpstreamAuthError.USER_NOT_FOUND, "User not found"); + } switch (context) { case "token": if (status == 400 || status == 401) { return new UpstreamAuthException(UpstreamAuthError.INVALID_CREDENTIALS, "Invalid login credentials"); } break; - case "verify": - if (status == 400 || status == 403) { + case VERIFY_CONTEXT: + if (status == 400 || status == 403 || status == 404) { return new UpstreamAuthException(UpstreamAuthError.TOKEN_INVALID, "Invalid or already used token"); } break; diff --git a/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java b/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java index c89ede1..0913fc3 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/config/SecurityConfig.java @@ -26,6 +26,7 @@ import java.io.IOException; import java.net.URI; import java.time.Instant; +import java.util.Locale; @Configuration @EnableWebSecurity @@ -103,7 +104,8 @@ private void writeProblem(HttpServletResponse response, HttpServletRequest reque ErrorCode code, String message) throws IOException { ProblemDetail problem = ProblemDetail.forStatusAndDetail(code.status(), message); problem.setTitle(code.status().getReasonPhrase()); - problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase())); + // Locale.ROOT: same stable-identifier reason as in GlobalExceptionHandler. + problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase(Locale.ROOT))); problem.setInstance(URI.create(request.getRequestURI())); problem.setProperty("errorCode", code.name()); problem.setProperty("traceId", MDC.get("traceId")); diff --git a/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java b/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java index 1da32eb..42aed9b 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverter.java @@ -9,6 +9,7 @@ import java.util.Collection; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -29,7 +30,10 @@ public Collection convert(Jwt jwt) { if (appMetadata instanceof Map metadata) { Object role = metadata.get(ROLE_KEY); if (role instanceof String roleValue && !roleValue.isBlank()) { - return List.of(new SimpleGrantedAuthority(ROLE_PREFIX + roleValue.toUpperCase())); + // Locale.ROOT: the authority name is a protocol value, not display text. + // Under a Turkish default locale "admin" would upper case to "ADMİN" + // and every hasRole("ADMIN") check would silently fail. + return List.of(new SimpleGrantedAuthority(ROLE_PREFIX + roleValue.toUpperCase(Locale.ROOT))); } } return List.of(); diff --git a/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java b/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java index 420c31a..e88c5d0 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/error/BusinessException.java @@ -6,8 +6,18 @@ public class BusinessException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final ErrorCode errorCode; - private final Map details; + /** + * Declared as {@link LinkedHashMap} — not as {@code Map} — because the field is + * serialised with the exception: a plain {@code Map} is not a serialisable type, + * which is what {@code javac -Xlint:serial} reports. Marking it {@code transient} + * would silence the same warning but drop the details on deserialisation, so it is + * the option that changes behaviour. The published view stays unmodifiable, and the + * copy stays defensive; only the declared type changed. + */ + private final LinkedHashMap details; public BusinessException(ErrorCode errorCode) { this(errorCode, errorCode.defaultMessage(), Collections.emptyMap()); @@ -20,7 +30,7 @@ public BusinessException(ErrorCode errorCode, String message) { public BusinessException(ErrorCode errorCode, String message, Map details) { super(message); this.errorCode = errorCode; - this.details = Collections.unmodifiableMap(new LinkedHashMap<>(details)); + this.details = new LinkedHashMap<>(details); } public static BusinessException of(ErrorCode errorCode, String message, String detailKey, String detailValue) { @@ -32,6 +42,6 @@ public ErrorCode errorCode() { } public Map details() { - return details; + return Collections.unmodifiableMap(details); } } diff --git a/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java b/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java index 2eb0d41..b1da145 100644 --- a/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java +++ b/src/main/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandler.java @@ -19,6 +19,7 @@ import java.net.URI; import java.time.Instant; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; @RestControllerAdvice @@ -83,7 +84,10 @@ public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) private ProblemDetail build(ErrorCode code, String message, HttpServletRequest request) { ProblemDetail problem = ProblemDetail.forStatusAndDetail(code.status(), message); problem.setTitle(code.status().is5xxServerError() ? HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase() : code.status().getReasonPhrase()); - problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase())); + // Locale.ROOT: the type URI is a stable identifier, not display text. + // Under a Turkish default locale VALIDATION_ERROR would lower case to + // "valıdatıon_error" and the published error type would stop matching. + problem.setType(URI.create("https://bookingplatform.codefactory.com/errors/" + code.name().toLowerCase(Locale.ROOT))); problem.setInstance(URI.create(request.getRequestURI())); problem.setProperty("errorCode", code.name()); problem.setProperty(TRACE_ID_PROPERTY, MDC.get("traceId")); diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java index 91e7d61..be7356d 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordRecoveryUseCaseTest.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.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -10,10 +12,11 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpStatus; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; @@ -106,30 +109,56 @@ void unknownEmailStillReachesProvider() { @DisplayName("Upstream failures") class UpstreamFailures { - @ParameterizedTest(name = "{0} is propagated to the caller") + @ParameterizedTest(name = "{0} is translated into a business rejection") + @EnumSource(value = UpstreamAuthError.class, + names = {"USER_NOT_FOUND", "RATE_LIMITED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any upstream failure other than an unknown user is reported as an upstream auth error") + void otherUpstreamErrorsBecomeUpstreamAuthError(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).sendPasswordRecovery(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("ANTI-ENUMERATION: a provider rate limit is answered 429, never as a generic 500") + void rateLimitIsTranslatedInsteadOfEscaping() { + doThrow(new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many emails")) + .when(identityProvider).sendPasswordRecovery(anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.requestRecovery("ana@example.com")); + + assertEquals(ErrorCode.RATE_LIMITED, ex.errorCode()); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, ex.errorCode().status()); + } + + @ParameterizedTest(name = "{0} never escapes as a raw UpstreamAuthException") @EnumSource(value = UpstreamAuthError.class, names = "USER_NOT_FOUND", mode = EnumSource.Mode.EXCLUDE) - @DisplayName("Any upstream failure other than an unknown user is propagated") - void otherUpstreamErrorsPropagate(UpstreamAuthError error) { + @DisplayName("No upstream failure escapes unmapped, so none of them can surface as a 500") + void noUpstreamErrorEscapesUnmapped(UpstreamAuthError error) { doThrow(new UpstreamAuthException(error, "gotrue said " + error)) .when(identityProvider).sendPasswordRecovery(anyString()); - UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.requestRecovery("ana@example.com")); - assertEquals(error, ex.error()); + assertNotEquals(ErrorCode.INTERNAL_ERROR, ex.errorCode()); } @Test - @DisplayName("The propagated failure is the very exception raised by the provider") - void propagatesTheOriginalException() { - UpstreamAuthException raised = - new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many emails"); - doThrow(raised).when(identityProvider).sendPasswordRecovery(anyString()); + @DisplayName("The upstream message is kept in the rejection so the trace stays useful") + void keepsTheUpstreamMessage() { + doThrow(new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out")) + .when(identityProvider).sendPasswordRecovery(anyString()); - UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.requestRecovery("ana@example.com")); - assertSame(raised, ex); + assertEquals("gotrue timed out", ex.getMessage()); } } } diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java index 11f98cd..0b44067 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/PasswordResetUseCaseTest.java @@ -20,7 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; @@ -197,31 +197,55 @@ void badTokenIsTranslated(UpstreamAuthError error) { assertEquals(ErrorCode.VERIFICATION_TOKEN_INVALID, ex.errorCode()); } - @ParameterizedTest(name = "{0} is propagated to the caller") + @ParameterizedTest(name = "{0} is translated into a business rejection") @EnumSource(value = UpstreamAuthError.class, - names = {"TOKEN_INVALID", "TOKEN_EXPIRED"}, mode = EnumSource.Mode.EXCLUDE) - @DisplayName("Any other upstream failure during the reset is propagated") - void otherUpstreamErrorsPropagate(UpstreamAuthError error) { + names = {"TOKEN_INVALID", "TOKEN_EXPIRED", "RATE_LIMITED"}, mode = EnumSource.Mode.EXCLUDE) + @DisplayName("Any other upstream failure during the reset is reported as an upstream auth error") + void otherUpstreamErrorsBecomeUpstreamAuthError(UpstreamAuthError error) { doThrow(new UpstreamAuthException(error, "gotrue said " + error)) .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); - UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); - assertEquals(error, ex.error()); + assertEquals(ErrorCode.UPSTREAM_AUTH_ERROR, ex.errorCode()); } @Test - @DisplayName("The propagated failure is the very exception raised by the provider") - void propagatesTheOriginalException() { - UpstreamAuthException raised = - new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out"); - doThrow(raised).when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + @DisplayName("A provider rate limit during the reset is answered 429, never as a generic 500") + void rateLimitIsTranslatedInsteadOfEscaping() { + doThrow(new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many resets")) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertEquals(ErrorCode.RATE_LIMITED, ex.errorCode()); + } + + @ParameterizedTest(name = "{0} never escapes as a raw UpstreamAuthException") + @EnumSource(UpstreamAuthError.class) + @DisplayName("No upstream failure escapes unmapped, so none of them can surface as a 500") + void noUpstreamErrorEscapesUnmapped(UpstreamAuthError error) { + doThrow(new UpstreamAuthException(error, "gotrue said " + error)) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); - UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + BusinessException ex = assertThrows(BusinessException.class, + () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); + + assertNotEquals(ErrorCode.INTERNAL_ERROR, ex.errorCode()); + } + + @Test + @DisplayName("The upstream message is kept in the rejection so the trace stays useful") + void keepsTheUpstreamMessage() { + doThrow(new UpstreamAuthException(UpstreamAuthError.UNAVAILABLE, "gotrue timed out")) + .when(identityProvider).resetPasswordWithToken(anyString(), anyString()); + + BusinessException ex = assertThrows(BusinessException.class, () -> useCase.resetPassword("token-hash", VALID_PASSWORD)); - assertSame(raised, ex); + assertEquals("gotrue timed out", ex.getMessage()); } } } diff --git a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java index 50bcd3a..66d675e 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/domain/model/UpstreamAuthExceptionTest.java @@ -75,6 +75,34 @@ void exceptionIsThrowable() { assertEquals(UpstreamAuthError.TOKEN_INVALID, thrown.error()); } + @Test + @DisplayName("The exception declares an explicit serialVersionUID so a redeploy cannot change it") + void declaresAnExplicitSerialVersionUid() throws Exception { + java.lang.reflect.Field field = UpstreamAuthException.class.getDeclaredField("serialVersionUID"); + field.setAccessible(true); + assertEquals(1L, field.getLong(null)); + } + + @Test + @DisplayName("A serialised upstream failure keeps its classified error and message") + void survivesJavaSerialisation() throws Exception { + UpstreamAuthException original = + new UpstreamAuthException(UpstreamAuthError.RATE_LIMITED, "too many calls"); + + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + try (java.io.ObjectOutputStream out = new java.io.ObjectOutputStream(bytes)) { + out.writeObject(original); + } + UpstreamAuthException restored; + try (java.io.ObjectInputStream in = + new java.io.ObjectInputStream(new java.io.ByteArrayInputStream(bytes.toByteArray()))) { + restored = (UpstreamAuthException) in.readObject(); + } + + assertEquals(UpstreamAuthError.RATE_LIMITED, restored.error()); + assertEquals("too many calls", restored.getMessage()); + } + @Test @DisplayName("A null message is accepted and reported back as null rather than as an empty string") void nullMessageIsPreserved() { diff --git a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java index af0593f..4a444c6 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java @@ -27,6 +27,8 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -189,16 +191,23 @@ static Stream precedenceTable() { // "email not confirmed" beats "user not found" when both appear. Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"email not confirmed\",\"hint\":\"user not found\"}", UpstreamAuthError.EMAIL_NOT_CONFIRMED), - // DEFECT (reported, not fixed): status 404 is evaluated BEFORE the "expired" - // rule, so an expired OTP answered with 404 is reported as USER_NOT_FOUND. - // Business-wise the caller should see TOKEN_EXPIRED. - Arguments.of(Endpoint.VERIFY, 404, "{\"msg\":\"Token has expired\"}", UpstreamAuthError.USER_NOT_FOUND), - // Same root cause: a 404 on /verify never reaches the "verify" branch, - // so an unknown/consumed token surfaces as USER_NOT_FOUND, not TOKEN_INVALID. - Arguments.of(Endpoint.VERIFY, 404, "", UpstreamAuthError.USER_NOT_FOUND), + // P4: GoTrue answers 404 to an expired OTP. The "expired" rule is checked + // before the 404 one, so a stale verification link is TOKEN_EXPIRED and the + // user is told the link expired, not that the account does not exist. + Arguments.of(Endpoint.VERIFY, 404, "{\"msg\":\"Token has expired\"}", UpstreamAuthError.TOKEN_EXPIRED), + // P5: a bare 404 on /verify is about the one-time token, not about a user, + // so it reaches the "verify" branch and is reported as TOKEN_INVALID. + Arguments.of(Endpoint.VERIFY, 404, "", UpstreamAuthError.TOKEN_INVALID), // "not found" in the body beats the token context: a 400 on /token whose body // mentions a missing user is USER_NOT_FOUND, not INVALID_CREDENTIALS. - Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND) + Arguments.of(Endpoint.TOKEN, 400, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND), + // The password reset shares the /verify context, so its 404 is classified the same way. + Arguments.of(Endpoint.RESET, 404, "", UpstreamAuthError.TOKEN_INVALID), + // The 404 exemption is about the status alone: an explicit "not found" body on + // /verify is still USER_NOT_FOUND. + Arguments.of(Endpoint.VERIFY, 404, "{\"msg\":\"User not found\"}", UpstreamAuthError.USER_NOT_FOUND), + // The exemption is scoped to /verify: an admin 404 keeps reporting a missing user. + Arguments.of(Endpoint.DELETE_USER, 404, "", UpstreamAuthError.USER_NOT_FOUND) ); } @@ -354,6 +363,20 @@ void nullIdFieldIsReportedAsUnavailable() { assertTrue(ex.getMessage().contains("missing field: id"), ex.getMessage()); } + @Test + @DisplayName("A malformed id is reported as UNAVAILABLE, not as a raw IllegalArgumentException") + void malformedIdIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andRespond(withSuccess("{\"id\":\"not-a-uuid\"}", MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("not a valid UUID"), ex.getMessage()); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + } + @Test @DisplayName("An empty body (no payload at all) is reported as UNAVAILABLE, not as a crash") void emptyBodyIsReportedAsUnavailable() { @@ -426,30 +449,47 @@ void missingTokenFieldsAreReportedAsUnavailable(String missingField, String body } @Test - @DisplayName("DEFECT: a non numeric expires_in escapes as a raw NumberFormatException") - void nonNumericExpiresInLeaksARawNumberFormatException() { + @DisplayName("A non numeric expires_in is an unusable provider answer: UNAVAILABLE") + void nonNumericExpiresInIsReportedAsUnavailable() { server.expect(requestTo(BASE + "/token?grant_type=password")) .andRespond(withSuccess(""" {"access_token":"jwt","refresh_token":"refresh","expires_in":"never"} """, MediaType.APPLICATION_JSON)); - // Expected by the contract: UpstreamAuthException(UNAVAILABLE). - // Real behaviour (GoTrueClient.java:97): Long.parseLong is outside the guarded - // conversion, so an unchecked NumberFormatException reaches the use case. - assertThrows(NumberFormatException.class, + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("expires_in"), ex.getMessage()); } @Test - @DisplayName("DEFECT: a decimal expires_in (valid JSON number) also escapes as NumberFormatException") - void decimalExpiresInLeaksARawNumberFormatException() { + @DisplayName("A decimal expires_in (valid JSON number) is reported as UNAVAILABLE too") + void decimalExpiresInIsReportedAsUnavailable() { server.expect(requestTo(BASE + "/token?grant_type=password")) .andRespond(withSuccess(""" {"access_token":"jwt","refresh_token":"refresh","expires_in":3600.0} """, MediaType.APPLICATION_JSON)); - assertThrows(NumberFormatException.class, + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("expires_in"), ex.getMessage()); + } + + @Test + @DisplayName("The failed expires_in conversion is kept as the cause for troubleshooting") + void badExpiresInKeepsTheConversionFailureAsCause() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"refresh","expires_in":"never"} + """, MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.requestPasswordToken("ana.perez@example.com", "Secret123!")); + + assertInstanceOf(NumberFormatException.class, ex.getCause()); } } @@ -553,18 +593,47 @@ void nonObjectUserFallsBackToTheRootPayload() { } @Test - @DisplayName("DEFECT: a confirmed user without email yields the literal string \"null\"") - void missingEmailBecomesTheLiteralStringNull() { + @DisplayName("A confirmed user without email still resolves: the id is what the caller consumes") + void missingEmailStillResolvesTheUser() { + // El correo no lo lee nadie: ConfirmEmailUseCase busca al cliente por userId. + // Exigirlo convertiria una respuesta sin ese campo en un 502 por un valor que + // se descarta, asi que se tolera su ausencia y se devuelve null, nunca "null". UUID id = UUID.randomUUID(); server.expect(requestTo(BASE + "/verify")) .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(id), MediaType.APPLICATION_JSON)); ConfirmedUser confirmed = client.verifyEmailToken("token-hash"); - // Expected by the contract: UNAVAILABLE (email is required to confirm a client) - // or at least a null email. Real behaviour (GoTrueClient.java:121): - // String.valueOf(null) produces the four character string "null". - assertEquals("null", confirmed.email()); + assertEquals(id, confirmed.userId()); + assertNull(confirmed.email(), "a missing email must never become the string \"null\""); + } + + @Test + @DisplayName("An explicit null email is treated exactly like a missing one, never as the string \"null\"") + void nullEmailNeverBecomesTheStringNull() { + UUID id = UUID.randomUUID(); + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"id\":\"%s\",\"email\":null}".formatted(id), + MediaType.APPLICATION_JSON)); + + ConfirmedUser confirmed = client.verifyEmailToken("token-hash"); + + assertEquals(id, confirmed.userId()); + assertNull(confirmed.email(), "String.valueOf(null) would have produced the 4-char string \"null\""); + } + + @Test + @DisplayName("A malformed user id is reported as UNAVAILABLE, not as a raw IllegalArgumentException") + void malformedUserIdIsReportedAsUnavailable() { + server.expect(requestTo(BASE + "/verify")) + .andRespond(withSuccess("{\"id\":\"not-a-uuid\",\"email\":\"ana.perez@example.com\"}", + MediaType.APPLICATION_JSON)); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, + () -> client.verifyEmailToken("token-hash")); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("not a valid UUID"), ex.getMessage()); } } 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..540a329 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java @@ -155,6 +155,25 @@ void errorAnswersDoNotLeakInternals() throws Exception { "the internal message leaked into the body: " + response.getContentAsString()); } + @Test + @DisplayName("The 401/403 type URIs do not depend on the JVM default locale (Turkish dotless-i trap)") + void errorTypeUrisAreLocaleIndependent() throws Exception { + java.util.Locale previous = java.util.Locale.getDefault(); + java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr-TR")); + try { + entryPoint().commence(request, response, new StubAuthenticationException()); + assertTrue(response.getContentAsString().contains("/errors/auth_required"), + "expected the ASCII type URI but got: " + response.getContentAsString()); + + MockHttpServletResponse forbidden = new MockHttpServletResponse(); + accessDeniedHandler().handle(request, forbidden, new AccessDeniedException("nope")); + assertTrue(forbidden.getContentAsString().contains("/errors/access_denied"), + "expected the ASCII type URI but got: " + forbidden.getContentAsString()); + } finally { + java.util.Locale.setDefault(previous); + } + } + @Test @DisplayName("The entry point is reused for both the resource server and the generic handling") void entryPointIsAvailable() { diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java index 6e22e9f..be5a56f 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SupabaseJwtAuthConverterTest.java @@ -14,6 +14,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -66,6 +67,24 @@ void roleFromAppMetadataIsUpperCasedAndPrefixed(String role, String expectedAuth assertEquals(List.of(expectedAuthority), authorityNames(authorities)); } + @ParameterizedTest(name = "under the Turkish locale [{0}] still grants [{1}]") + @CsvSource({ + "admin, ROLE_ADMIN", + "client, ROLE_CLIENT", + "i, ROLE_I"}) + @DisplayName("The authority name does not depend on the JVM default locale (Turkish dotted-I trap)") + void roleUpperCasingIsLocaleIndependent(String role, String expectedAuthority) { + Locale previous = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + try { + Collection authorities = converter.convert(jwtWithAppMetadata(Map.of("role", role))); + + assertEquals(List.of(expectedAuthority), authorityNames(authorities)); + } finally { + Locale.setDefault(previous); + } + } + @Test @DisplayName("Exactly one authority is granted, never a duplicate") void grantsASingleAuthority() { diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java index 042611a..1e69076 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/BusinessExceptionTest.java @@ -113,6 +113,63 @@ void exceptionIsUnchecked() { } } + @Nested + @DisplayName("Java serialisation") + class Serialisation { + + private static BusinessException roundTrip(BusinessException original) throws Exception { + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + try (java.io.ObjectOutputStream out = new java.io.ObjectOutputStream(bytes)) { + out.writeObject(original); + } + try (java.io.ObjectInputStream in = + new java.io.ObjectInputStream(new java.io.ByteArrayInputStream(bytes.toByteArray()))) { + return (BusinessException) in.readObject(); + } + } + + @Test + @DisplayName("The exception declares an explicit serialVersionUID so a redeploy cannot change it") + void declaresAnExplicitSerialVersionUid() throws Exception { + java.lang.reflect.Field field = BusinessException.class.getDeclaredField("serialVersionUID"); + field.setAccessible(true); + assertEquals(1L, field.getLong(null)); + } + + @Test + @DisplayName("A serialised rejection keeps its error code and message") + void roundTripKeepsCodeAndMessage() throws Exception { + BusinessException restored = roundTrip( + new BusinessException(ErrorCode.DUPLICATE_DOCUMENT, "document already used")); + + assertEquals(ErrorCode.DUPLICATE_DOCUMENT, restored.errorCode()); + assertEquals("document already used", restored.getMessage()); + } + + @Test + @DisplayName("A serialised rejection keeps its details: the detail map is not transient") + void roundTripKeepsTheDetails() throws Exception { + Map ordered = new LinkedHashMap<>(); + ordered.put("first", "1"); + ordered.put("second", "2"); + + BusinessException restored = roundTrip( + new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", ordered)); + + assertEquals(ordered, restored.details()); + assertIterableEquals(List.of("first", "second"), restored.details().keySet()); + } + + @Test + @DisplayName("The details published by a deserialised rejection are still unmodifiable") + void roundTripKeepsTheDetailsUnmodifiable() throws Exception { + BusinessException restored = roundTrip( + new BusinessException(ErrorCode.VALIDATION_ERROR, "invalid", Map.of("email", "blank"))); + + assertThrows(UnsupportedOperationException.class, () -> restored.details().put("k", "v")); + } + } + @Nested @DisplayName("of factory") class OfFactory { diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java index a4866e3..97a1be2 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java @@ -125,6 +125,22 @@ void publishesTheTypeUri() { assertEquals(URI.create(TYPE_PREFIX + "duplicate_email"), problem.getType()); } + @ParameterizedTest(name = "{0} keeps its type URI under the Turkish locale") + @EnumSource(ErrorCode.class) + @DisplayName("The type URI does not depend on the JVM default locale (Turkish dotless-i trap)") + void typeUriIsLocaleIndependent(ErrorCode code) { + java.util.Locale previous = java.util.Locale.getDefault(); + java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr-TR")); + try { + ProblemDetail problem = handler.handleBusiness(new BusinessException(code), request); + + assertEquals(URI.create(TYPE_PREFIX + code.name().toLowerCase(java.util.Locale.ROOT)), + problem.getType()); + } finally { + java.util.Locale.setDefault(previous); + } + } + @Test @DisplayName("A business rejection points the instance at the URI of the request that failed") void instanceIsTheRequestUri() { From 4b959749c10602fb0b80aac26bd1b040e67c1578 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 08/12] test: verify the acceptance criteria no test was covering Six criteria of HU-001 and HU-021 had no test at all. Three are now verified, two turned out to be defects, and one stays half open on purpose. The one-time link is the property that gives the criterion its name and only expiry was being tested, never reuse. Confirming an email twice with the same token_hash now has to be rejected, and so does redeeming a recovery link twice; a password that fails the policy must not burn the link either. This needed an identity provider double that remembers what it has already issued and consumed, so the existing mock grew that state. The JWT decoder was never executed: the tests injected the authorities directly and skipped it. It now runs against a JWKS served over HTTP from a local server with keys generated in the test, so a wrong issuer, an expired token and a forged signature are rejected for real. No new dependency. Logout does not invalidate the access token. The revocation reaches the provider, but the token is self-contained and validated offline, so it keeps working until it expires. ADR-0003 accepts that trade-off; the criterion is written in absolute terms, so it is recorded as partially met. Spring MVC cannot translate its own status codes. The advice is ordered ahead of everything and handles Exception, so a wrong method answers 500 instead of 405 and without an Allow header, an unsupported media type answers 500 instead of 415, and an unknown path answers 500 instead of 404. Each one is also logged at ERROR with a full stack trace, so ordinary client mistakes read as server failures. The invariant that only a verified client may confirm a booking cannot be verified end to end because there is no booking flow yet. Rather than invent one, it is checked against the aggregate reread from PostgreSQL, and three guards are armed for the day the flow arrives: the invariant must stay public, nobody outside Client may read ClientStatus.ACTIVE, and any future booking class must call canConfirmBooking. The happy path of tomorrow's demo is covered end to end: register, confirm, log in, read the profile, log out, checking the HTTP status and the database row at each step. --- .../acceptance/HappyPathAcceptanceIT.java | 132 ++++++++++ .../acceptance/OneTimeLinkIT.java | 192 ++++++++++++++ .../BookingConfirmationInvariantTest.java | 105 ++++++++ .../bookingplatform/auth/JwtValidationIT.java | 98 +++++++ .../auth/SessionInvalidationIT.java | 115 +++++++++ .../ClientVerificationInvariantIT.java | 79 ++++++ .../shared/error/HttpStatusTranslationIT.java | 108 ++++++++ .../support/JwtIntegrationTestBase.java | 77 ++++++ .../support/LocalJwksServer.java | 71 +++++ .../support/SimulatedIdentityProvider.java | 242 ++++++++++++++++++ .../bookingplatform/support/TestJwks.java | 113 ++++++++ 11 files changed, 1332 insertions(+) create mode 100644 src/test/java/com/codefactory/bookingplatform/acceptance/HappyPathAcceptanceIT.java create mode 100644 src/test/java/com/codefactory/bookingplatform/acceptance/OneTimeLinkIT.java create mode 100644 src/test/java/com/codefactory/bookingplatform/architecture/BookingConfirmationInvariantTest.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/JwtValidationIT.java create mode 100644 src/test/java/com/codefactory/bookingplatform/auth/SessionInvalidationIT.java create mode 100644 src/test/java/com/codefactory/bookingplatform/identity/ClientVerificationInvariantIT.java create mode 100644 src/test/java/com/codefactory/bookingplatform/shared/error/HttpStatusTranslationIT.java create mode 100644 src/test/java/com/codefactory/bookingplatform/support/JwtIntegrationTestBase.java create mode 100644 src/test/java/com/codefactory/bookingplatform/support/LocalJwksServer.java create mode 100644 src/test/java/com/codefactory/bookingplatform/support/SimulatedIdentityProvider.java create mode 100644 src/test/java/com/codefactory/bookingplatform/support/TestJwks.java diff --git a/src/test/java/com/codefactory/bookingplatform/acceptance/HappyPathAcceptanceIT.java b/src/test/java/com/codefactory/bookingplatform/acceptance/HappyPathAcceptanceIT.java new file mode 100644 index 0000000..65315ad --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/acceptance/HappyPathAcceptanceIT.java @@ -0,0 +1,132 @@ +package com.codefactory.bookingplatform.acceptance; + +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientEntity; +import com.codefactory.bookingplatform.support.JwtIntegrationTestBase; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * HU-001 + HU-021 end to end, over HTTP and against PostgreSQL: the exact path shown in the demo. + * + *

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

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

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

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

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

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

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

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

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

This test pins the behaviour that exists today. It is deliberately written to fail the + * moment someone closes the gap - at which point the expectation below becomes 401 and the + * criterion is finally met. + */ + @Test + @DisplayName("HU-021 DEFECT D8: after logout the access token is still accepted (no revocation check)") + void accessTokenSurvivesLogout() throws Exception { + String accessToken = loginAndGetAccessToken(); + + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + + int statusAfterLogout = mockMvc.perform(get("/api/v1/auth/me") + .header("Authorization", "Bearer " + accessToken)) + .andReturn().getResponse().getStatus(); + + assertEquals(200, statusAfterLogout, + "Current behaviour pinned: HU-021 asks for 401 here. If this now returns 401 the defect " + + "was fixed - flip the expectation and move this test out of the DEFECT group."); + } + + @Test + @DisplayName("HU-021 AC: logging out twice is idempotent and never leaks an upstream error") + void logoutIsIdempotent() throws Exception { + String accessToken = loginAndGetAccessToken(); + + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("HU-021 AC: logout without a bearer token is rejected with 401") + void logoutRequiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("AUTH_REQUIRED")); + } + + private String loginAndGetAccessToken() throws Exception { + mockMvc.perform(post("/api/v1/registrations") + .contentType(MediaType.APPLICATION_JSON) + .content(registrationPayload(EMAIL, DOCUMENT))) + .andExpect(status().isCreated()); + mockMvc.perform(post("/api/v1/registrations/email-verifications") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tokenHash\":\"" + identityProvider.currentEmailToken(EMAIL) + "\"}")) + .andExpect(status().isOk()); + + String body = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return com.jayway.jsonpath.JsonPath.read(body, "$.accessToken"); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/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 ""; + } + try { + return com.jayway.jsonpath.JsonPath.read(body, "$.errorCode"); + } catch (RuntimeException ex) { + return ""; + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/support/JwtIntegrationTestBase.java b/src/test/java/com/codefactory/bookingplatform/support/JwtIntegrationTestBase.java new file mode 100644 index 0000000..25cd43b --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/support/JwtIntegrationTestBase.java @@ -0,0 +1,77 @@ +package com.codefactory.bookingplatform.support; + +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.auth.infrastructure.persistence.LoginAttemptJpaRepository; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration base for the tests that need the real JWT pipeline. + * + *

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

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

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

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

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

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

The production {@code SecurityConfig.jwtDecoder()} validates the signature against the + * JWKS published by Supabase and then checks {@code exp}/{@code nbf} and {@code iss}. Tests that + * want to exercise that decoder for real need two keys: one published in the JWK set (so tokens + * signed with it verify) and one kept out of it (so tokens signed with it fail the signature + * check exactly like a forged token would). + */ +public final class TestJwks { + + public static final String KEY_ID = "bookingplatform-test-key"; + + private final RSAKey publishedKey; + private final RSAKey strangerKey; + private final String issuer; + + public TestJwks(String issuer) { + this.issuer = issuer; + try { + this.publishedKey = new RSAKeyGenerator(2048) + .keyID(KEY_ID) + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .generate(); + this.strangerKey = new RSAKeyGenerator(2048) + .keyID(KEY_ID) // same kid on purpose: only the signature tells them apart + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .generate(); + } catch (JOSEException ex) { + throw new IllegalStateException("Could not generate the test RSA keys", ex); + } + } + + public String issuer() { + return issuer; + } + + /** The public JWK set, exactly as the identity provider would publish it. */ + public String jwkSetJson() { + return new JWKSet(publishedKey.toPublicJWK()).toString(); + } + + /** A well formed, currently valid access token for the given user. */ + public String accessToken(UUID userId, String email, String role) { + return sign(publishedKey, claims(userId, email, role, issuer, Instant.now().minusSeconds(5), + Instant.now().plus(Duration.ofMinutes(15)))); + } + + /** Valid signature and issuer, but already expired: must fail {@code JwtTimestampValidator}. */ + public String expiredAccessToken(UUID userId, String email, String role) { + Instant issuedAt = Instant.now().minus(Duration.ofHours(2)); + return sign(publishedKey, claims(userId, email, role, issuer, issuedAt, issuedAt.plus(Duration.ofMinutes(15)))); + } + + /** Valid signature, but minted by a different issuer: must fail {@code JwtIssuerValidator}. */ + public String tokenFromAnotherIssuer(UUID userId, String email, String role) { + return sign(publishedKey, claims(userId, email, role, "https://evil.example.com/auth/v1", + Instant.now().minusSeconds(5), Instant.now().plus(Duration.ofMinutes(15)))); + } + + /** Right claims and right {@code kid}, signed with a key that is not in the JWK set. */ + public String tokenWithForgedSignature(UUID userId, String email, String role) { + return sign(strangerKey, claims(userId, email, role, issuer, Instant.now().minusSeconds(5), + Instant.now().plus(Duration.ofMinutes(15)))); + } + + private static JWTClaimsSet claims(UUID userId, String email, String role, String issuer, + Instant issuedAt, Instant expiresAt) { + return new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(userId.toString()) + .audience("authenticated") + .issueTime(Date.from(issuedAt)) + .expirationTime(Date.from(expiresAt)) + .claim("email", email) + .claim("role", "authenticated") + .claim("app_metadata", Map.of("role", role)) + .claim("user_metadata", Map.of("role", "ADMIN")) // must be ignored by the converter + .build(); + } + + private static String sign(RSAKey key, JWTClaimsSet claims) { + try { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(), claims); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } catch (JOSEException ex) { + throw new IllegalStateException("Could not sign the test token", ex); + } + } +} 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/") + void probeGroupsExist() { + HealthEndpointGroups groups = context.getBean(HealthEndpointGroups.class); + + assertTrue(groups.getNames().contains("liveness")); + assertTrue(groups.getNames().contains("readiness")); + } + + @Test + @DisplayName("RISK: the readiness group does not include db, so Render sees UP even with the database down") + void readinessIgnoresTheDatabase() { + HealthEndpointGroup readiness = context.getBean(HealthEndpointGroups.class).get("readiness"); + + assertNotNull(readiness); + assertFalse(readiness.isMember("db"), + "healthCheckPath is /actuator/health/readiness, which only reflects the application " + + "availability state, never the datasource"); + } + + @Test + @DisplayName("The database contributor is registered under db and is the JDBC one") + void databaseContributorIsRegistered() { + HealthIndicator indicator = (HealthIndicator) context.getBean("dbHealthContributor"); + + assertInstanceOf(DataSourceHealthIndicator.class, indicator); + assertEquals(Status.UP, indicator.health().getStatus()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/ApplicationContextStartupIT.java b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationContextStartupIT.java new file mode 100644 index 0000000..1722bb6 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationContextStartupIT.java @@ -0,0 +1,288 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.application.UserProvisioning; +import com.codefactory.bookingplatform.auth.domain.port.IdentityProviderPort; +import com.codefactory.bookingplatform.auth.domain.port.LoginAttemptRepository; +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.auth.infrastructure.persistence.LoginAttemptJpaRepository; +import com.codefactory.bookingplatform.auth.infrastructure.supabase.GoTrueClient; +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.port.ClientRepository; +import com.codefactory.bookingplatform.identity.infrastructure.mapper.ClientMapper; +import com.codefactory.bookingplatform.identity.infrastructure.persistence.ClientJpaRepository; +import com.codefactory.bookingplatform.shared.config.AuthPolicyProperties; +import com.codefactory.bookingplatform.shared.config.SecurityProperties; +import com.codefactory.bookingplatform.shared.config.SupabaseJwtAuthConverter; +import com.codefactory.bookingplatform.shared.config.SupabaseProperties; +import com.codefactory.bookingplatform.shared.error.GlobalExceptionHandler; +import com.codefactory.bookingplatform.shared.observability.TraceIdFilter; +import com.codefactory.bookingplatform.support.PostgresIntegrationTestBase; +import io.swagger.v3.oas.models.OpenAPI; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpHeaders; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.client.RestClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.cors.CorsConfigurationSource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.time.Clock; +import java.time.Duration; +import java.time.ZoneOffset; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; + +/** + * The application context under the {@code test} profile: it must start, and it + * must start complete. A context that loads but is missing a bean, or that + * builds a policy from the wrong numbers, is a deployment that fails later and + * in production, which is exactly what this class exists to prevent. + */ +class ApplicationContextStartupIT extends PostgresIntegrationTestBase { + + @Autowired + private ApplicationContext context; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("The context starts") + class ContextStarts { + + @Test + @DisplayName("The application context is available and running") + void contextIsUp() { + assertNotNull(context); + assertNotNull(context.getId()); + } + + @Test + @DisplayName("The test profile is the active one, so the test datasource and schema apply") + void testProfileIsActive() { + assertEquals(Set.of("test"), Set.of(context.getEnvironment().getActiveProfiles())); + } + + @Test + @DisplayName("The schema strategy under test is create-drop, so every run starts from an empty database") + void schemaIsRecreatedForTheSuite() { + assertEquals("create-drop", context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto")); + } + } + + @Nested + @DisplayName("Domain policy beans carry the configured values") + class PolicyBeans { + + @Test + @DisplayName("LoginLockPolicy is built from app.auth-policy: 5 attempts, 15 minute window") + void loginLockPolicyMatchesConfiguration() { + LoginLockPolicy policy = context.getBean(LoginLockPolicy.class); + + assertEquals(5, policy.maxFailedAttempts()); + assertEquals(Duration.ofMinutes(15), policy.lockWindow()); + } + + @Test + @DisplayName("LoginLockPolicy agrees with the AuthPolicyProperties bean it was built from") + void loginLockPolicyAgreesWithItsProperties() { + AuthPolicyProperties properties = context.getBean(AuthPolicyProperties.class); + LoginLockPolicy policy = context.getBean(LoginLockPolicy.class); + + assertEquals(properties.maxFailedAttempts(), policy.maxFailedAttempts()); + assertEquals(Duration.ofMinutes(properties.lockWindowMinutes()), policy.lockWindow()); + } + + @Test + @DisplayName("The Clock bean runs on UTC, so audit timestamps do not drift with the host zone") + void clockIsUtc() { + assertEquals(ZoneOffset.UTC, context.getBean(Clock.class).getZone()); + } + + @Test + @DisplayName("There is exactly one Clock bean, so nothing can inject a different notion of now") + void clockIsUnique() { + assertEquals(1, context.getBeanNamesForType(Clock.class).length); + } + } + + @Nested + @DisplayName("Security beans") + class SecurityBeans { + + @Test + @DisplayName("The JwtDecoder is a Nimbus decoder, which is the JWKS-backed one") + void jwtDecoderIsNimbus() { + assertInstanceOf(NimbusJwtDecoder.class, context.getBean(JwtDecoder.class)); + } + + @Test + @DisplayName("There is exactly one SecurityFilterChain, so no second chain can shadow the rules") + void singleSecurityFilterChain() { + assertEquals(1, context.getBeanNamesForType(SecurityFilterChain.class).length); + assertNotNull(context.getBean(SecurityFilterChain.class)); + } + + @Test + @DisplayName("The security properties point at the Supabase auth endpoints of the configured project") + void securityPropertiesArePresent() { + SecurityProperties properties = context.getBean(SecurityProperties.class); + + assertTrue(properties.jwtIssuer().endsWith("/auth/v1")); + assertTrue(properties.jwksUri().endsWith("/.well-known/jwks.json")); + } + + @Test + @DisplayName("The Supabase JWT converter bean is registered, which is what maps app_metadata.role") + void jwtConverterIsRegistered() { + assertNotNull(context.getBean(SupabaseJwtAuthConverter.class)); + } + + @Test + @DisplayName("RISK: the only CorsConfigurationSource is Spring MVC's introspector, no application CORS policy exists") + void thereIsNoApplicationDefinedCorsPolicy() { + String[] names = context.getBeanNamesForType(CorsConfigurationSource.class); + + assertEquals(1, names.length); + assertEquals("mvcHandlerMappingIntrospector", names[0], + "http.cors(withDefaults()) falls back to the MVC introspector; " + + "render.yaml declares ALLOWED_ORIGINS but nothing turns it into a policy"); + } + + @Test + @DisplayName("RISK: a cross origin preflight gets no Access-Control-Allow-Origin, so a browser frontend is blocked") + void preflightGetsNoCorsHeaders() throws Exception { + mockMvc.perform(options("/api/v1/auth/login") + .header(HttpHeaders.ORIGIN, "https://frontend.example.com") + .header("Access-Control-Request-Method", "POST")) + .andExpect(header().doesNotExist("Access-Control-Allow-Origin")); + } + } + + @Nested + @DisplayName("Persistence beans") + class PersistenceBeans { + + @Test + @DisplayName("Both Spring Data repositories are created") + void jpaRepositoriesExist() { + assertNotNull(context.getBean(ClientJpaRepository.class)); + assertNotNull(context.getBean(LoginAttemptJpaRepository.class)); + } + + @Test + @DisplayName("Both domain ports are satisfied by their persistence adapters") + void domainPortsAreAdapted() { + assertNotNull(context.getBean(ClientRepository.class)); + assertNotNull(context.getBean(LoginAttemptRepository.class)); + } + + @Test + @DisplayName("The MapStruct mapper is a Spring bean, which is what the adapters inject") + void mapperIsABean() { + assertNotNull(context.getBean(ClientMapper.class)); + } + + @Test + @DisplayName("The DataSource hands out a working connection to the real database") + void dataSourceConnects() throws Exception { + try (Connection connection = context.getBean(DataSource.class).getConnection()) { + assertTrue(connection.isValid(5)); + assertEquals("PostgreSQL", connection.getMetaData().getDatabaseProductName()); + } + } + + @Test + @DisplayName("Hibernate created the two Sprint 1 tables") + void schemaWasCreated() throws Exception { + try (Connection connection = context.getBean(DataSource.class).getConnection(); + var statement = connection.createStatement(); + var rows = statement.executeQuery( + "select count(*) from information_schema.tables " + + "where table_schema = 'public' and table_name in ('clients','login_attempts')")) { + assertTrue(rows.next()); + assertEquals(2, rows.getInt(1)); + } + } + } + + @Nested + @DisplayName("Application and adapter beans") + class ApplicationBeans { + + @Test + @DisplayName("Every authentication use case is wired") + void authUseCasesExist() { + assertNotNull(context.getBean(LoginUseCase.class)); + assertNotNull(context.getBean(LogoutUseCase.class)); + assertNotNull(context.getBean(PasswordRecoveryUseCase.class)); + assertNotNull(context.getBean(PasswordResetUseCase.class)); + assertNotNull(context.getBean(UserProvisioning.class)); + } + + @Test + @DisplayName("Every registration use case is wired") + void identityUseCasesExist() { + assertNotNull(context.getBean(RegisterClientUseCase.class)); + assertNotNull(context.getBean(ConfirmEmailUseCase.class)); + assertNotNull(context.getBean(ResendVerificationUseCase.class)); + } + + @Test + @DisplayName("The identity provider port is satisfied by the Supabase GoTrue adapter") + void identityProviderIsGoTrue() { + assertInstanceOf(GoTrueClient.class, context.getBean(IdentityProviderPort.class)); + } + + @Test + @DisplayName("The RestClient builder used to reach Supabase is available from the container") + void restClientBuilderIsAvailable() { + assertNotNull(context.getBean(RestClient.Builder.class)); + } + + @Test + @DisplayName("The Supabase properties are bound and carry a non blank URL") + void supabasePropertiesAreBound() { + SupabaseProperties properties = context.getBean(SupabaseProperties.class); + + assertNotNull(properties); + assertFalse(properties.url().isBlank()); + } + + @Test + @DisplayName("The cross cutting beans are registered: trace id filter and the error handler") + void crossCuttingBeansExist() { + assertNotNull(context.getBean(TraceIdFilter.class)); + assertNotNull(context.getBean(GlobalExceptionHandler.class)); + } + + @Test + @DisplayName("The OpenAPI contract bean is built, so Swagger UI has something to render") + void openApiBeanExists() { + OpenAPI openApi = context.getBean(OpenAPI.class); + + assertEquals("Booking Platform API", openApi.getInfo().getTitle()); + assertEquals("v1", openApi.getInfo().getVersion()); + } + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/ApplicationStartsIT.java b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationStartsIT.java new file mode 100644 index 0000000..803cb26 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/ApplicationStartsIT.java @@ -0,0 +1,110 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.support.PostgresIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The application running for real: an embedded Tomcat on a random port, a + * PostgreSQL in Docker behind it and plain HTTP requests from outside the + * container. Everything else in this package inspects the context; this class + * checks that the process actually serves traffic, which is the question the + * demo answers. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ApplicationStartsIT extends PostgresIntegrationTestBase { + + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + @LocalServerPort + private int port; + + private HttpResponse get(String path) throws Exception { + return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .timeout(Duration.ofSeconds(20)) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body) throws Exception { + return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), HttpResponse.BodyHandlers.ofString()); + } + + @Test + @DisplayName("The embedded server is listening on a real port") + void serverIsListening() { + assertTrue(port > 0); + } + + @Test + @DisplayName("GET /actuator/health answers 200 UP over real HTTP") + void healthAnswersOverHttp() throws Exception { + HttpResponse response = get("/actuator/health"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"status\":\"UP\""), response.body()); + } + + @Test + @DisplayName("GET /actuator/health/readiness, the path render.yaml polls, answers 200 over real HTTP") + void readinessAnswersOverHttp() throws Exception { + HttpResponse response = get("/actuator/health/readiness"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"status\":\"UP\""), response.body()); + } + + @Test + @DisplayName("The OpenAPI contract is served publicly, so Swagger UI works without a token") + void openApiContractIsPublic() throws Exception { + HttpResponse response = get("/v3/api-docs"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("Booking Platform API"), response.body()); + } + + @Test + @DisplayName("A public endpoint is reachable without a token: a malformed registration gets 400, not 401") + void publicEndpointAnswersWithoutToken() throws Exception { + HttpResponse response = postJson("/api/v1/registrations", "{}"); + + assertEquals(400, response.statusCode(), response.body()); + assertTrue(response.body().contains("VALIDATION_ERROR"), response.body()); + } + + @Test + @DisplayName("A protected endpoint answers 401 AUTH_REQUIRED when no token is sent") + void protectedEndpointRequiresAToken() throws Exception { + HttpResponse response = get("/api/v1/auth/me"); + + assertEquals(401, response.statusCode(), response.body()); + assertTrue(response.body().contains("AUTH_REQUIRED"), response.body()); + assertTrue(response.headers().firstValue("Content-Type").orElse("") + .startsWith("application/problem+json"), response.headers().toString()); + } + + @Test + @DisplayName("Every error response carries the X-Trace-Id header used to correlate the logs") + void errorsCarryATraceId() throws Exception { + HttpResponse response = get("/api/v1/auth/me"); + + assertTrue(response.headers().firstValue("X-Trace-Id").isPresent(), response.headers().toString()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java b/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java new file mode 100644 index 0000000..a757120 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java @@ -0,0 +1,138 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.auth.domain.service.LoginLockPolicy; +import com.codefactory.bookingplatform.shared.config.SupabaseProperties; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.Statement; +import java.time.Duration; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The profile that actually ships. Render runs the image with + * {@code SPRING_PROFILES_ACTIVE=cloud}, and that profile pins + * {@code ddl-auto=validate}: Hibernate refuses to start if the database does + * not already match the entity mapping. + * + *

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

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

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

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

The last test of the first group is the one that matters for an operator: + * the resulting error reaching the caller is {@code INVALID_CREDENTIALS}, which + * reads as "wrong password" and points the diagnosis away from the real cause. + */ +class MissingSupabaseCredentialTest { + + private static final String BASE = "https://demo.supabase.co"; + private static final String NO_KEY = ""; + + private MockRestServiceServer server; + private GoTrueClient clientWithoutCredential; + + @BeforeEach + void setUp() { + RestClient.Builder realBuilder = RestClient.builder().baseUrl(BASE); + server = MockRestServiceServer.bindTo(realBuilder).build(); + RestClient.Builder builder = mock(RestClient.Builder.class); + when(builder.baseUrl(anyString())).thenReturn(builder); + when(builder.build()).thenReturn(realBuilder.build()); + clientWithoutCredential = new GoTrueClient(new SupabaseProperties(BASE, NO_KEY), builder); + } + + @Nested + @DisplayName("The identity adapter accepts an empty secret key") + class AdapterIsBuiltAnyway { + + @Test + @DisplayName("RISK: building the adapter with an empty secret key does not fail, so nothing warns at startup") + void adapterIsBuiltWithoutCredential() { + assertNotNull(clientWithoutCredential); + } + + @Test + @DisplayName("RISK: the login call leaves with an empty apikey header instead of being refused locally") + void loginRequestCarriesAnEmptyApiKey() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", "")) + .andRespond(withStatus(org.springframework.http.HttpStatus.UNAUTHORIZED) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"message\":\"Invalid API key\"}")); + + assertThrows(UpstreamAuthException.class, + () -> clientWithoutCredential.requestPasswordToken("ana.perez@example.com", "Str0ng!Pass")); + server.verify(); + } + + @Test + @DisplayName("RISK: Supabase answering 401 to an unauthenticated login is reported as INVALID_CREDENTIALS") + void missingCredentialLooksLikeAWrongPassword() { + server.expect(requestTo(BASE + "/token?grant_type=password")) + .andRespond(withStatus(org.springframework.http.HttpStatus.UNAUTHORIZED) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"message\":\"Invalid API key\"}")); + + UpstreamAuthException failure = assertThrows(UpstreamAuthException.class, + () -> clientWithoutCredential.requestPasswordToken("ana.perez@example.com", "Str0ng!Pass")); + + assertEquals(UpstreamAuthError.INVALID_CREDENTIALS, failure.error(), + "a missing deployment credential is reported to the user as a wrong password"); + } + + @Test + @DisplayName("Registration fails with UNAVAILABLE instead, so the same root cause shows two different faces") + void registrationReportsUnavailable() { + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("apikey", "")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer ")) + .andRespond(withStatus(org.springframework.http.HttpStatus.UNAUTHORIZED) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"message\":\"Invalid API key\"}")); + + UpstreamAuthException failure = assertThrows(UpstreamAuthException.class, + () -> clientWithoutCredential.createUser("ana.perez@example.com", "Str0ng!Pass", AppRole.CLIENT)); + + assertEquals(UpstreamAuthError.UNAVAILABLE, failure.error()); + server.verify(); + } + } + + @Nested + @DisplayName("The JWT decoder accepts an unreachable JWKS URI") + class JwtDecoderIsBuiltAnyway { + + private final SecurityConfig config = new SecurityConfig( + new SecurityProperties("https://placeholder.supabase.co/auth/v1", + "https://placeholder.supabase.co/auth/v1/.well-known/jwks.json"), + new ObjectMapper()); + + @Test + @DisplayName("RISK: the decoder is built without ever contacting the JWKS endpoint") + void decoderIsBuiltWithoutContactingTheJwksEndpoint() { + assertDoesNotThrow(config::jwtDecoder, + "a wrong SUPABASE_URL is invisible until the first token arrives"); + } + + @Test + @DisplayName("The misconfiguration only surfaces when a token is decoded") + void failureSurfacesOnFirstDecode() { + JwtDecoder decoder = config.jwtDecoder(); + + assertThrows(JwtException.class, () -> decoder.decode("not-a-real-token")); + } + } +} 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 @@ 1.4.1 1.21.4 0.8.13 + 1.19.1 + 1.2.2 @@ -282,6 +284,93 @@ + + + org.pitest + pitest-maven + ${pitest.version} + + + org.pitest + pitest-junit5-plugin + ${pitest.junit5.version} + + + + + com.codefactory.bookingplatform.* + + + com.codefactory.bookingplatform.* + + + + com.codefactory.bookingplatform.BookingPlatformApplication + com.codefactory.bookingplatform.*.api.dto.* + com.codefactory.bookingplatform.auth.domain.model.AppRole + com.codefactory.bookingplatform.auth.domain.model.AuthTokens + com.codefactory.bookingplatform.auth.domain.model.ConfirmedUser + com.codefactory.bookingplatform.auth.domain.model.UpstreamAuthError + com.codefactory.bookingplatform.identity.domain.model.ClientStatus + com.codefactory.bookingplatform.identity.domain.model.NotificationChannel + com.codefactory.bookingplatform.identity.application.RegisterClientCommand + com.codefactory.bookingplatform.identity.application.RegistrationOutcome + com.codefactory.bookingplatform.shared.error.ErrorCode + com.codefactory.bookingplatform.shared.config.AuthPolicyProperties + com.codefactory.bookingplatform.shared.config.SecurityProperties + com.codefactory.bookingplatform.shared.config.SupabaseProperties + com.codefactory.bookingplatform.shared.config.OpenApiConfig + com.codefactory.bookingplatform.shared.config.ClockConfig + com.codefactory.bookingplatform.shared.persistence.JpaAuditingConfig + + + + com.codefactory.bookingplatform.*IT + + + ALL + + + HTML + XML + + false + 4 + 8000 + + 95 + + diff --git a/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerTest.java b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerTest.java new file mode 100644 index 0000000..11ce34a --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/auth/api/AuthControllerTest.java @@ -0,0 +1,129 @@ +package com.codefactory.bookingplatform.auth.api; + +import com.codefactory.bookingplatform.auth.api.dto.LoginRequest; +import com.codefactory.bookingplatform.auth.api.dto.LoginResponse; +import com.codefactory.bookingplatform.auth.api.dto.MeResponse; +import com.codefactory.bookingplatform.auth.api.dto.PasswordRecoveryRequest; +import com.codefactory.bookingplatform.auth.api.dto.PasswordResetRequest; +import com.codefactory.bookingplatform.auth.application.LoginUseCase; +import com.codefactory.bookingplatform.auth.application.LogoutUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordRecoveryUseCase; +import com.codefactory.bookingplatform.auth.application.PasswordResetUseCase; +import com.codefactory.bookingplatform.auth.domain.model.AuthTokens; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The controller is a delegating layer: every endpoint must reach its own use case with the values + * the caller sent, and nothing else. These are plain unit tests without an application context, so + * each endpoint is exercised on its own instead of relying on a single cached Spring context to + * cover the whole class. + */ +class AuthControllerTest { + + private static final UUID SUBJECT = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + private final LoginUseCase loginUseCase = mock(LoginUseCase.class); + private final LogoutUseCase logoutUseCase = mock(LogoutUseCase.class); + private final PasswordRecoveryUseCase passwordRecoveryUseCase = mock(PasswordRecoveryUseCase.class); + private final PasswordResetUseCase passwordResetUseCase = mock(PasswordResetUseCase.class); + + private final AuthController controller = new AuthController( + loginUseCase, logoutUseCase, passwordRecoveryUseCase, passwordResetUseCase); + + private JwtAuthenticationToken authentication(String tokenValue, String... authorities) { + Jwt jwt = Jwt.withTokenValue(tokenValue) + .header("alg", "ES256") + .subject(SUBJECT.toString()) + .claim("email", "ana.perez@example.com") + .issuedAt(Instant.now().minusSeconds(60)) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + return new JwtAuthenticationToken(jwt, + List.of(authorities).stream().map(SimpleGrantedAuthority::new).map(a -> (org.springframework.security.core.GrantedAuthority) a).toList()); + } + + @Test + @DisplayName("Login forwards the submitted credentials to the login use case") + void loginForwardsCredentials() { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access", "refresh", "bearer", 3600L)); + + controller.login(new LoginRequest("ana.perez@example.com", "Str0ng!Pass")); + + verify(loginUseCase).login("ana.perez@example.com", "Str0ng!Pass"); + } + + @Test + @DisplayName("Login answers with the tokens the provider issued, not with a placeholder") + void loginReturnsTheIssuedTokens() { + when(loginUseCase.login(anyString(), anyString())) + .thenReturn(new AuthTokens("access", "refresh", "bearer", 3600L)); + + LoginResponse response = controller.login(new LoginRequest("ana.perez@example.com", "Str0ng!Pass")); + + assertEquals("access", response.accessToken()); + assertEquals("refresh", response.refreshToken()); + assertEquals("bearer", response.tokenType()); + assertEquals(3600L, response.expiresIn()); + } + + @Test + @DisplayName("Logout invalidates the raw bearer token of the caller, not the subject id") + void logoutForwardsTheBearerToken() { + controller.logout(authentication("the-access-token")); + + verify(logoutUseCase).logout("the-access-token"); + } + + @Test + @DisplayName("A password recovery request reaches the recovery use case with the submitted email") + void recoveryForwardsTheEmail() { + controller.requestPasswordRecovery(new PasswordRecoveryRequest("ana.perez@example.com")); + + verify(passwordRecoveryUseCase).requestRecovery("ana.perez@example.com"); + } + + @Test + @DisplayName("A password reset forwards both the one-time token hash and the new password") + void resetForwardsTokenAndPassword() { + controller.resetPassword(new PasswordResetRequest("token-hash", "NewSecret123!")); + + verify(passwordResetUseCase).resetPassword("token-hash", "NewSecret123!"); + } + + @Test + @DisplayName("The profile endpoint reads id and email from the token claims") + void meReadsTheTokenClaims() { + MeResponse response = controller.me(authentication("token", "ROLE_CLIENT")); + + assertEquals(SUBJECT, response.id()); + assertEquals("ana.perez@example.com", response.email()); + } + + @Test + @DisplayName("The profile endpoint publishes the role without its ROLE_ prefix") + void meStripsTheRolePrefix() { + assertEquals("CLIENT", controller.me(authentication("token", "ROLE_CLIENT")).role()); + } + + @Test + @DisplayName("A token that grants no ROLE_ authority yields no role at all") + void meReportsNoRoleWhenNoneIsGranted() { + assertNull(controller.me(authentication("token", "SCOPE_openid")).role()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java b/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java index f063db2..df48475 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/application/UserProvisioningServiceTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -89,6 +90,15 @@ void weakPasswordIsRejected(String password, String brokenRule) { assertEquals(ErrorCode.PASSWORD_TOO_WEAK, ex.errorCode()); } + @Test + @DisplayName("The weak password rejection carries the policy message, not an empty detail") + void weakPasswordCarriesTheDefaultMessage() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.provisionClientUser("ana@example.com", "abcdef1!")); + + assertEquals(ErrorCode.PASSWORD_TOO_WEAK.defaultMessage(), ex.getMessage()); + } + @ParameterizedTest(name = "[{0}] never reaches the identity provider") @ValueSource(strings = {"abcdef1!", "ABCDEF1!", "Abcdefg!", "Abcdefg1", "Abc1!"}) @DisplayName("A weak password is rejected before any user is created upstream") @@ -397,6 +407,19 @@ void otherUpstreamErrorsAreEscalated(UpstreamAuthError error) { assertThrows(BusinessException.class, () -> service.confirmEmail("token-hash")); } + @ParameterizedTest(name = "{0} during confirmation keeps its own error code") + @EnumSource(value = UpstreamAuthError.class, + names = {"RATE_LIMITED", "USER_ALREADY_EXISTS", "EMAIL_NOT_CONFIRMED"}) + @DisplayName("A confirmation failure that is not about the token keeps its own meaning") + void nonTokenFailuresKeepTheirOwnCode(UpstreamAuthError error) { + when(identityProvider.verifyEmailToken(anyString())).thenThrow(upstream(error)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.confirmEmail("token-hash")); + + assertNotEquals(ErrorCode.VERIFICATION_TOKEN_INVALID, ex.errorCode()); + } + @Test @DisplayName("Confirming an email for an unknown user is reported as RESOURCE_NOT_FOUND") void unknownUserIsMapped() { diff --git a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java index af0593f..f68288c 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java @@ -30,10 +30,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; import static org.springframework.test.web.client.match.MockRestRequestMatchers.headerDoesNotExist; import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; @@ -657,4 +661,97 @@ void otherLogoutFailuresUseTheGeneralTable(int status, UpstreamAuthError expecte assertEquals(expected, ex.error()); } } + + // --------------------------------------------------------------------- + // Request envelope: base url, content type and the exact shape of the + // body. These are the parts a "did it call the endpoint" assertion never + // looks at, and the parts a wrong value breaks silently in production. + // --------------------------------------------------------------------- + + @Nested + @DisplayName("Request envelope") + class RequestEnvelope { + + @Test + @DisplayName("Every call is rooted at the GoTrue path of the configured Supabase project") + void clientIsRootedAtTheProjectAuthPath() { + RestClient.Builder builder = mock(RestClient.Builder.class); + when(builder.baseUrl(anyString())).thenReturn(builder); + when(builder.build()).thenReturn(RestClient.builder().build()); + + new GoTrueClient(new SupabaseProperties("https://other.supabase.co", SECRET), builder); + + verify(builder).baseUrl("https://other.supabase.co/auth/v1"); + } + + @ParameterizedTest(name = "[{index}] {0} declares application/json") + @CsvSource({ + "TOKEN, /token?grant_type=password", + "VERIFY, /verify", + "RESEND, /resend", + "RECOVER, /recover" + }) + @DisplayName("The JSON body is announced with an explicit Content-Type, not left to the converter") + void jsonBodiesDeclareTheContentType(Endpoint endpoint, String path) { + server.expect(requestTo(BASE + path)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andRespond(withSuccess(""" + {"access_token":"jwt","refresh_token":"r","user":{"id":"%s","email":"ana.perez@example.com"}} + """.formatted(UUID.randomUUID()), MediaType.APPLICATION_JSON)); + + invoke(endpoint); + + server.verify(); + } + + @Test + @DisplayName("Creating a user announces application/json too") + void createUserDeclaresTheContentType() { + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(UUID.randomUUID()), + MediaType.APPLICATION_JSON)); + + client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + + server.verify(); + } + + @Test + @DisplayName("A new user is created unconfirmed: the confirmation must come from the emailed link") + void createUserDoesNotAutoConfirmTheEmail() { + server.expect(requestTo(BASE + "/admin/users")) + .andExpect(jsonPath("$.email_confirm").value(false)) + .andRespond(withSuccess("{\"id\":\"%s\"}".formatted(UUID.randomUUID()), + MediaType.APPLICATION_JSON)); + + client.createUser("ana.perez@example.com", "Secret123!", AppRole.CLIENT); + + server.verify(); + } + + @Test + @DisplayName("Email verification omits the password key entirely instead of sending it as null") + void emailVerificationOmitsThePasswordKeyEntirely() { + server.expect(requestTo(BASE + "/verify")) + .andExpect(content().string(not(containsString("password")))) + .andRespond(withSuccess("{\"id\":\"%s\",\"email\":\"ana.perez@example.com\"}" + .formatted(UUID.randomUUID()), MediaType.APPLICATION_JSON)); + + client.verifyEmailToken("token-hash"); + + server.verify(); + } + + @Test + @DisplayName("A dead session reports what the provider answered, so the cause is not lost") + void deadSessionKeepsTheUpstreamDetail() { + server.expect(requestTo(BASE + "/logout")) + .andRespond(withStatus(HttpStatusCode.valueOf(401)).body("{\"msg\":\"token already revoked\"}")); + + UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> client.signOut("stale-token")); + + assertTrue(ex.getMessage().contains("token already revoked"), ex.getMessage()); + } + } } diff --git a/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerTest.java b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerTest.java new file mode 100644 index 0000000..0b47695 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/identity/api/RegistrationControllerTest.java @@ -0,0 +1,122 @@ +package com.codefactory.bookingplatform.identity.api; + +import com.codefactory.bookingplatform.identity.api.dto.ConfirmEmailRequest; +import com.codefactory.bookingplatform.identity.api.dto.ConfirmEmailResponse; +import com.codefactory.bookingplatform.identity.api.dto.RegisterClientRequest; +import com.codefactory.bookingplatform.identity.api.dto.RegisterClientResponse; +import com.codefactory.bookingplatform.identity.api.dto.ResendVerificationRequest; +import com.codefactory.bookingplatform.identity.application.ConfirmEmailUseCase; +import com.codefactory.bookingplatform.identity.application.RegisterClientCommand; +import com.codefactory.bookingplatform.identity.application.RegisterClientUseCase; +import com.codefactory.bookingplatform.identity.application.RegistrationOutcome; +import com.codefactory.bookingplatform.identity.application.ResendVerificationUseCase; +import com.codefactory.bookingplatform.identity.domain.model.ClientStatus; +import com.codefactory.bookingplatform.identity.domain.model.NotificationChannel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The registration controller only translates between the HTTP payload and the use cases. What has + * to hold is that every field of the request reaches the command untouched and in the right slot, + * and that each endpoint talks to its own use case. + */ +class RegistrationControllerTest { + + private static final UUID CLIENT_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + private final RegisterClientUseCase registerClientUseCase = mock(RegisterClientUseCase.class); + private final ResendVerificationUseCase resendVerificationUseCase = mock(ResendVerificationUseCase.class); + private final ConfirmEmailUseCase confirmEmailUseCase = mock(ConfirmEmailUseCase.class); + + private final RegistrationController controller = new RegistrationController( + registerClientUseCase, resendVerificationUseCase, confirmEmailUseCase); + + private RegisterClientRequest request() { + return new RegisterClientRequest( + "Ana Perez", + "1017245896", + LocalDate.of(1995, 3, 14), + "ana.perez@example.com", + "+573001112233", + "Medellin", + NotificationChannel.EMAIL, + "Str0ng!Pass"); + } + + @Test + @DisplayName("Registration hands every submitted field to the use case in its own slot") + void registrationMapsTheWholeRequest() { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.PENDING_VERIFICATION)); + + controller.register(request()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegisterClientCommand.class); + verify(registerClientUseCase).register(captor.capture()); + RegisterClientCommand command = captor.getValue(); + assertEquals("Ana Perez", command.fullName()); + assertEquals("1017245896", command.document()); + assertEquals(LocalDate.of(1995, 3, 14), command.birthDate()); + assertEquals("ana.perez@example.com", command.email()); + assertEquals("+573001112233", command.phone()); + assertEquals("Medellin", command.city()); + assertEquals(NotificationChannel.EMAIL, command.notificationChannel()); + assertEquals("Str0ng!Pass", command.password()); + } + + @Test + @DisplayName("Registration answers with the client id, email and status the use case produced") + void registrationAnswersWithTheOutcome() { + when(registerClientUseCase.register(any())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.PENDING_VERIFICATION)); + + RegisterClientResponse response = controller.register(request()); + + assertEquals(CLIENT_ID, response.clientId()); + assertEquals("ana.perez@example.com", response.email()); + assertEquals(ClientStatus.PENDING_VERIFICATION, response.status()); + } + + @Test + @DisplayName("A verification resend reaches the resend use case with the submitted email") + void resendForwardsTheEmail() { + controller.resendVerification(new ResendVerificationRequest("ana.perez@example.com")); + + verify(resendVerificationUseCase).resend("ana.perez@example.com"); + } + + @Test + @DisplayName("Email confirmation forwards the one-time token hash to the confirmation use case") + void confirmForwardsTheTokenHash() { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + controller.confirmEmail(new ConfirmEmailRequest("token-hash")); + + verify(confirmEmailUseCase).confirm("token-hash"); + } + + @Test + @DisplayName("A confirmed email is answered with the activated client") + void confirmAnswersWithTheActivatedClient() { + when(confirmEmailUseCase.confirm(anyString())) + .thenReturn(new RegistrationOutcome(CLIENT_ID, "ana.perez@example.com", ClientStatus.ACTIVE)); + + ConfirmEmailResponse response = controller.confirmEmail(new ConfirmEmailRequest("token-hash")); + + assertEquals(CLIENT_ID, response.clientId()); + assertEquals("ana.perez@example.com", response.email()); + assertEquals(ClientStatus.ACTIVE, response.status()); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigJwtDecoderTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigJwtDecoderTest.java new file mode 100644 index 0000000..32dc60e --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigJwtDecoderTest.java @@ -0,0 +1,171 @@ +package com.codefactory.bookingplatform.shared.config; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The JWT decoder is the single gate that turns a bearer string into an authenticated caller, so it + * is exercised against a real JWKS endpoint and real signatures rather than against a mock. + * + *

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

+ */ +class SecurityConfigJwtDecoderTest { + + private static final String ISSUER = "https://demo.supabase.co/auth/v1"; + private static final String SUBJECT = "11111111-2222-3333-4444-555555555555"; + + private static HttpServer jwksServer; + private static ECKey ecKey; + private static RSAKey rsaKey; + private static String jwksUri; + + /** + * Built per test on purpose. A decoder built once in {@code @BeforeAll} is only ever attributed + * to whichever test happens to run first, which hides the bean factory from any per-test + * analysis; building it here means every rule below exercises the factory itself. + */ + private final JwtDecoder decoder = new SecurityConfig( + new SecurityProperties(ISSUER, jwksUri), new tools.jackson.databind.ObjectMapper()).jwtDecoder(); + + @BeforeAll + static void startJwksEndpoint() throws Exception { + ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec-1").generate(); + rsaKey = new RSAKeyGenerator(2048).keyID("rsa-1").generate(); + byte[] jwks = new JWKSet(java.util.List.of(ecKey.toPublicJWK(), rsaKey.toPublicJWK())) + .toString().getBytes(StandardCharsets.UTF_8); + + jwksServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + jwksServer.createContext("/jwks.json", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, jwks.length); + exchange.getResponseBody().write(jwks); + exchange.close(); + }); + jwksServer.start(); + + jwksUri = "http://127.0.0.1:" + jwksServer.getAddress().getPort() + "/jwks.json"; + } + + @AfterAll + static void stopJwksEndpoint() { + jwksServer.stop(0); + } + + private static JWTClaimsSet claims(String issuer, Instant expiresAt) { + return new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(SUBJECT) + .claim("email", "ana.perez@example.com") + .issueTime(Date.from(expiresAt.minusSeconds(3600))) + .expirationTime(Date.from(expiresAt)) + .build(); + } + + private static String signedWithEc(JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(ecKey.getKeyID()).type(JOSEObjectType.JWT).build(), + claims); + jwt.sign(new ECDSASigner(ecKey)); + return jwt.serialize(); + } + + private static String signedWithRsa(JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).type(JOSEObjectType.JWT).build(), + claims); + jwt.sign(new RSASSASigner(rsaKey)); + return jwt.serialize(); + } + + @Test + @DisplayName("An ES256 token signed by the project keys is accepted: that is what Supabase issues today") + void es256TokenIsAccepted() throws Exception { + Jwt jwt = decoder.decode(signedWithEc(claims(ISSUER, Instant.now().plusSeconds(3600)))); + + assertEquals(SUBJECT, jwt.getSubject()); + assertEquals("ana.perez@example.com", jwt.getClaimAsString("email")); + } + + @Test + @DisplayName("An RS256 token signed by the project keys is accepted: legacy Supabase projects still sign that way") + void rs256TokenIsAccepted() throws Exception { + Jwt jwt = decoder.decode(signedWithRsa(claims(ISSUER, Instant.now().plusSeconds(3600)))); + + assertEquals(SUBJECT, jwt.getSubject()); + } + + @Test + @DisplayName("An expired token is refused, so a leaked token stops working when it lapses") + void expiredTokenIsRefused() throws Exception { + String expired = signedWithEc(claims(ISSUER, Instant.now().minusSeconds(600))); + + assertThrows(JwtException.class, () -> decoder.decode(expired)); + } + + @Test + @DisplayName("An expired RS256 token is refused as well, so the second algorithm is validated too") + void expiredRsaTokenIsRefused() throws Exception { + String expired = signedWithRsa(claims(ISSUER, Instant.now().minusSeconds(600))); + + assertThrows(JwtException.class, () -> decoder.decode(expired)); + } + + @Test + @DisplayName("SECURITY: a token minted by another issuer is refused even if the signature checks out") + void foreignIssuerIsRefused() throws Exception { + String foreign = signedWithEc(claims("https://attacker.example.com/auth/v1", Instant.now().plusSeconds(3600))); + + assertThrows(JwtException.class, () -> decoder.decode(foreign)); + } + + @Test + @DisplayName("SECURITY: a token with no issuer claim at all is refused") + void missingIssuerIsRefused() throws Exception { + String noIssuer = signedWithEc(claims(null, Instant.now().plusSeconds(3600))); + + assertThrows(JwtException.class, () -> decoder.decode(noIssuer)); + } + + @Test + @DisplayName("SECURITY: an HS256 token is refused, so a caller cannot sign with a guessed shared secret") + void symmetricallySignedTokenIsRefused() throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.HS256).build(), + claims(ISSUER, Instant.now().plusSeconds(3600))); + jwt.sign(new MACSigner("0123456789012345678901234567890123456789".getBytes(StandardCharsets.UTF_8))); + String symmetric = jwt.serialize(); + + assertThrows(JwtException.class, () -> decoder.decode(symmetric)); + } +} diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/SecurityConfigTest.java 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 passwordCorpus() { "Str0ng!Pass"); } - @ParameterizedTest(name = "isValid and violations agree for [{0}]") - @MethodSource("passwordCorpus") - @DisplayName("isValid is true exactly when the violation list is empty") - void isValidAgreesWithViolations(String password) { - assertEquals(PasswordPolicy.violations(password).isEmpty(), PasswordPolicy.isValid(password), - () -> "isValid disagreed with violations() for [" + password + "]"); + @ParameterizedTest(name = "isValid({0}) is {1}") + @CsvSource({ + // La contraseña, y si la política debe aceptarla. El veredicto se escribe aquí, + // no se calcula: comparar isValid contra violations().isEmpty() asevera X == X, + // porque isValid está implementado exactamente así, y pasaría aunque la política + // estuviese desactivada por completo. + "'Segura#2026', true", + "'Abcdefg1!', true", + "'Sin1nguno', false", // sin carácter especial + "'sinmayuscula1!', false", // sin mayúscula + "'SINMINUSCULA1!', false", // sin minúscula + "'SinDigitos!!', false", // sin dígito + "'Ab1!', false", // por debajo del mínimo + "'abcdefgh', false" // solo minúsculas + }) + @DisplayName("The policy accepts a password only when it satisfies every rule at once") + void isValidReflectsTheWholePolicy(String password, boolean esperado) { + assertEquals(esperado, PasswordPolicy.isValid(password), + () -> "the policy disagreed on [" + password + "]"); } @Test diff --git a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java index 14132f6..59300fa 100644 --- a/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java +++ b/src/test/java/com/codefactory/bookingplatform/auth/infrastructure/supabase/GoTrueClientTest.java @@ -103,14 +103,60 @@ private void invoke(Endpoint endpoint) { } } - /** Answers whatever request arrives with the given status and body, then runs the endpoint. */ + /** The path each endpoint must call. Anchoring it here makes every row of the tables below + * verify the operation as well as the classification: without it the matcher accepts any + * request and an endpoint calling the wrong path would go unnoticed. */ + private static String pathOf(Endpoint endpoint) { + return switch (endpoint) { + case CREATE_USER, DELETE_USER -> "/admin/users"; + case TOKEN -> "/token"; + case VERIFY, RESET -> "/verify"; + case RESEND -> "/resend"; + case RECOVER -> "/recover"; + case LOGOUT -> "/logout"; + }; + } + + /** Answers the request with the given status and body, then runs the endpoint. */ private UpstreamAuthException callExpectingFailure(Endpoint endpoint, int status, String body) { - server.expect(request -> { }).andRespond(withStatus(HttpStatusCode.valueOf(status)).body(body)); + String esperado = pathOf(endpoint); + server.expect(request -> assertTrue(request.getURI().getPath().startsWith(esperado), + () -> endpoint + " called " + request.getURI().getPath() + " instead of " + esperado)) + .andRespond(withStatus(HttpStatusCode.valueOf(status)).body(body)); UpstreamAuthException ex = assertThrows(UpstreamAuthException.class, () -> invoke(endpoint)); server.verify(); return ex; } + static Stream endpointContexts() { + return Stream.of( + Arguments.of(Endpoint.CREATE_USER, "createUser"), + Arguments.of(Endpoint.DELETE_USER, "deleteUser"), + Arguments.of(Endpoint.TOKEN, "token"), + Arguments.of(Endpoint.VERIFY, "verify"), + // El reseteo comparte el endpoint /verify, y por tanto su contexto. + Arguments.of(Endpoint.RESET, "verify"), + Arguments.of(Endpoint.RESEND, "resend"), + Arguments.of(Endpoint.RECOVER, "recover"), + Arguments.of(Endpoint.LOGOUT, "logout")); + } + + @ParameterizedTest(name = "{0} classifies its failures under the \"{1}\" context") + @MethodSource("endpointContexts") + @DisplayName("Each endpoint hands the mapper its own context, which is what decides the classification") + void everyEndpointCarriesItsOwnErrorContext(Endpoint endpoint, String context) { + // El contexto es un literal que decide cómo se traduce el fallo: con "token" un 400 es + // INVALID_CREDENTIALS y con "verify" es TOKEN_INVALID. Las tablas de abajo aseveran el + // error resultante, pero hay combinaciones donde dos contextos coinciden, así que un + // literal intercambiado puede pasar inadvertido. Un 502 no lo mapea ninguna regla y cae + // en la rama por defecto, que es la única que nombra el contexto: eso lo ancla. + UpstreamAuthException ex = callExpectingFailure(endpoint, 502, ""); + + assertEquals(UpstreamAuthError.UNAVAILABLE, ex.error()); + assertTrue(ex.getMessage().contains("in " + context + ":"), + () -> "expected the " + context + " context in: " + ex.getMessage()); + } + // --------------------------------------------------------------------- // mapError: decision table (black box) // --------------------------------------------------------------------- diff --git a/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java b/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java index d6aba3d..b1ecb3b 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/config/ConfigurationBeansTest.java @@ -17,7 +17,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; - import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -35,29 +34,15 @@ class ClockConfigTest { private final ClockConfig config = new ClockConfig(); @Test - @DisplayName("The clock bean is built and is not null") - void clockIsProvided() { - assertNotNull(config.clock()); - } - - @Test - @DisplayName("The clock runs on UTC, so stored timestamps do not drift with the host zone") - void clockIsUtc() { - assertEquals(ZoneOffset.UTC, config.clock().getZone()); - } - - @Test - @DisplayName("The clock is the system UTC clock, not a fixed or offset one") + @DisplayName("The clock is the system UTC clock: neither frozen, nor offset, nor on the host zone") void clockIsSystemUtc() { + // Una sola aserción cubre las tres cosas, y de forma determinista: Clock.fixed, + // Clock.offset y systemDefaultZone son todos distintos de systemUTC. Comparar el + // instante contra "hace un minuto" no sirve, porque un reloj congelado en el + // instante actual también lo cumple; y leerlo dos veces esperando que avance + // depende de la resolución del reloj del sistema y sale intermitente en Windows. assertEquals(Clock.systemUTC(), config.clock()); - } - - @Test - @DisplayName("The clock ticks with real time instead of returning a frozen instant") - void clockIsNotFixed() { - Clock clock = config.clock(); - - assertTrue(clock.instant().isAfter(java.time.Instant.now().minusSeconds(60))); + assertEquals(ZoneOffset.UTC, config.clock().getZone()); } } @@ -119,10 +104,5 @@ void isAConfigurationClass() { assertNotNull(JpaAuditingConfig.class.getAnnotation(Configuration.class)); } - @Test - @DisplayName("The configuration class can be instantiated by the container") - void isInstantiable() { - assertNotNull(new JpaAuditingConfig()); - } } } diff --git a/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java b/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java index 97a1be2..dae884a 100644 --- a/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/codefactory/bookingplatform/shared/error/GlobalExceptionHandlerTest.java @@ -580,8 +580,17 @@ class CommonEnvelope { @Test @DisplayName("Every response carries a timestamp so the client can tell two identical failures apart") void publishesATimestamp() { + java.time.Instant antes = java.time.Instant.now().minusSeconds(1); + ProblemDetail problem = handler.handleBusiness(new BusinessException(ErrorCode.ACCESS_DENIED), request); - assertNotNull(propertiesOf(problem).get("timestamp")); + + // assertNotNull pasaría con cualquier cadena, incluida una constante: hay que + // comprobar que es un instante y que corresponde a esta respuesta. + Object publicado = propertiesOf(problem).get("timestamp"); + assertNotNull(publicado); + java.time.Instant sello = java.time.Instant.parse(publicado.toString()); + assertTrue(sello.isAfter(antes) && sello.isBefore(java.time.Instant.now().plusSeconds(1)), + () -> "the timestamp does not belong to this response: " + sello); } @Test