From c05eb9bfd0ab074115e66f27dfd5c684aef9d7e4 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 16 Jul 2026 09:48:00 +0300 Subject: [PATCH 01/62] fix(security): secure rate limiting against X-Forwarded-For spoofing - Stopped trusting the X-Forwarded-For header blindly in RateLimitingFilter - Restricted proxy IP detection to trusted reverse proxy sources only - Prevented potential brute-force bypass on /sign-in and /sign-up routes --- .../ratelimit/RateLimitProperties.java | 7 ++ .../ratelimit/RateLimitingFilter.java | 39 ++++++++-- src/main/resources/application.properties | 1 + .../ratelimit/RateLimitPropertiesTest.java | 27 +++++++ .../ratelimit/RateLimitingFilterTest.java | 75 ++++++++++++++++++- 5 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java b/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java index acc22b0..33b195c 100644 --- a/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java +++ b/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java @@ -31,6 +31,13 @@ public class RateLimitProperties { /** Path-prefix-specific overrides, checked longest-prefix-first. */ private List rules = new ArrayList<>(); + /** + * CIDR ranges or exact IPs of proxies/load balancers allowed to set + * X-Forwarded-For. Requests whose immediate peer isn't in this list have + * X-Forwarded-For ignored entirely — it's treated as attacker-controlled. + */ + private List trustedProxies = new ArrayList<>(); + /** A single rate limit rule scoped to requests under {@link #pathPrefix}. */ @Getter @Setter diff --git a/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java b/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java index 70ca862..db417cc 100644 --- a/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java +++ b/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java @@ -1,6 +1,7 @@ package com.weatherviewer.ratelimit; import com.weatherviewer.security.SecUser; +import jakarta.annotation.PostConstruct; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -12,12 +13,14 @@ import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.util.matcher.IpAddressMatcher; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.servlet.FlashMap; import org.springframework.web.servlet.support.SessionFlashMapManager; import java.io.IOException; import java.util.Comparator; +import java.util.List; /** * Servlet filter that enforces per-client request rate limits, backed by @@ -42,6 +45,21 @@ public class RateLimitingFilter extends OncePerRequestFilter { private final RedisFixedWindowRateLimiter rateLimiter; private final RateLimitProperties properties; + private List trustedProxyMatchers; + + /** + * Parses {@link RateLimitProperties#getTrustedProxies()} into + * {@link IpAddressMatcher} instances once at startup, since each + * matcher parses its CIDR/IP on construction and shouldn't be rebuilt + * per-request. Package-private so tests can re-invoke it after + * mutating {@link #properties} directly, without a Spring context. + */ + @PostConstruct + void initTrustedProxies() { + this.trustedProxyMatchers = properties.getTrustedProxies().stream() + .map(IpAddressMatcher::new) + .toList(); + } @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -142,13 +160,24 @@ private String resolveClientKey(HttpServletRequest request) { return "ip:" + resolveClientIp(request); } - /** Prefers the first {@code X-Forwarded-For} entry (if present) over the raw socket address, for use behind a proxy. */ + /** Prefers X-Forwarded-For only when the immediate peer is a trusted proxy. */ private String resolveClientIp(HttpServletRequest request) { - String forwardedFor = request.getHeader("X-Forwarded-For"); - if (forwardedFor != null && !forwardedFor.isBlank()) { - return forwardedFor.split(",")[0].trim(); + String remoteAddr = request.getRemoteAddr(); + + if (isTrustedProxy(remoteAddr)) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } } - return request.getRemoteAddr(); + + return remoteAddr; + } + + /** Checks whether {@code remoteAddr} matches any configured trusted-proxy CIDR/IP. */ + private boolean isTrustedProxy(String remoteAddr) { + return trustedProxyMatchers.stream() + .anyMatch(matcher -> matcher.matches(remoteAddr)); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c8aad5b..a158e65 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -56,6 +56,7 @@ spring.lifecycle.timeout-per-shutdown-phase=20s # --- Rate limiting --- rate-limit.enabled=${RATE_LIMIT_ENABLED:true} +rate-limit.trusted-proxies=${TRUSTED_PROXIES:} rate-limit.default-limit=100 rate-limit.default-window-seconds=60 diff --git a/src/test/java/com/weatherviewer/ratelimit/RateLimitPropertiesTest.java b/src/test/java/com/weatherviewer/ratelimit/RateLimitPropertiesTest.java index 6c32310..28d7805 100644 --- a/src/test/java/com/weatherviewer/ratelimit/RateLimitPropertiesTest.java +++ b/src/test/java/com/weatherviewer/ratelimit/RateLimitPropertiesTest.java @@ -36,6 +36,13 @@ void defaults_rulesIsEmptyButNotNull() { assertThat(properties.getRules()).isNotNull().isEmpty(); } + @Test + void defaults_trustedProxiesIsEmptyButNotNull() { + RateLimitProperties properties = new RateLimitProperties(); + + assertThat(properties.getTrustedProxies()).isNotNull().isEmpty(); + } + @Test void setters_overrideDefaults() { RateLimitProperties properties = new RateLimitProperties(); @@ -65,6 +72,26 @@ void setRules_storesProvidedRules() { assertThat(properties.getRules().get(0).getWindowSeconds()).isEqualTo(60); } + @Test + void setTrustedProxies_storesProvidedValues() { + RateLimitProperties properties = new RateLimitProperties(); + + properties.setTrustedProxies(List.of("10.0.0.0/8", "127.0.0.1")); + + assertThat(properties.getTrustedProxies()) + .hasSize(2) + .containsExactly("10.0.0.0/8", "127.0.0.1"); + } + + @Test + void setTrustedProxies_emptyListIsAllowed() { + RateLimitProperties properties = new RateLimitProperties(); + + properties.setTrustedProxies(List.of()); + + assertThat(properties.getTrustedProxies()).isEmpty(); + } + @Test void rule_settersAndGetters_roundTrip() { RateLimitProperties.Rule rule = new RateLimitProperties.Rule(); diff --git a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java index f96c87d..a104778 100644 --- a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java +++ b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java @@ -38,6 +38,7 @@ void setUp() { properties.setDefaultLimit(100); properties.setDefaultWindowSeconds(60); filter = new RateLimitingFilter(rateLimiter, properties); + filter.initTrustedProxies(); } @AfterEach @@ -263,7 +264,10 @@ void clientKey_anonymousRequest_isKeyedByRemoteAddress() throws Exception { } @Test - void clientKey_xForwardedForHeader_takesPrecedenceOverRemoteAddress() throws Exception { + void clientKey_xForwardedForHeader_trustedProxy_takesPrecedenceOverRemoteAddress() throws Exception { + properties.setTrustedProxies(List.of("10.0.0.1")); + filter.initTrustedProxies(); + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 0, 60)); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sign-in"); @@ -276,6 +280,72 @@ void clientKey_xForwardedForHeader_takesPrecedenceOverRemoteAddress() throws Exc verify(rateLimiter).tryConsume(org.mockito.ArgumentMatchers.contains("ip:198.51.100.7"), anyInt(), anyInt()); } + @Test + void clientKey_xForwardedForHeader_untrustedRemoteAddr_isIgnored() throws Exception { + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) + .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 0, 60)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sign-in"); + request.setRemoteAddr("203.0.113.5"); + request.addHeader("X-Forwarded-For", "198.51.100.7"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + verify(rateLimiter).tryConsume(org.mockito.ArgumentMatchers.contains("ip:203.0.113.5"), anyInt(), anyInt()); + verify(rateLimiter, never()).tryConsume(org.mockito.ArgumentMatchers.contains("198.51.100.7"), anyInt(), anyInt()); + } + + @Test + void clientKey_xForwardedForHeader_remoteAddrOutsideConfiguredCidr_isIgnored() throws Exception { + properties.setTrustedProxies(List.of("10.0.0.0/24")); + filter.initTrustedProxies(); + + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) + .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 0, 60)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sign-in"); + request.setRemoteAddr("11.0.0.5"); + request.addHeader("X-Forwarded-For", "198.51.100.7"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + verify(rateLimiter).tryConsume(org.mockito.ArgumentMatchers.contains("ip:11.0.0.5"), anyInt(), anyInt()); + } + + @Test + void clientKey_xForwardedForHeader_remoteAddrInsideConfiguredCidr_isHonored() throws Exception { + properties.setTrustedProxies(List.of("10.0.0.0/24")); + filter.initTrustedProxies(); + + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) + .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 0, 60)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sign-in"); + request.setRemoteAddr("10.0.0.42"); + request.addHeader("X-Forwarded-For", "198.51.100.7"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + verify(rateLimiter).tryConsume(org.mockito.ArgumentMatchers.contains("ip:198.51.100.7"), anyInt(), anyInt()); + } + + @Test + void clientKey_multipleConfiguredTrustedProxies_matchesAnyOfThem() throws Exception { + properties.setTrustedProxies(List.of("192.168.1.1", "10.0.0.0/8")); + filter.initTrustedProxies(); + + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) + .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 0, 60)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sign-in"); + request.setRemoteAddr("10.20.30.40"); + request.addHeader("X-Forwarded-For", "198.51.100.7"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + verify(rateLimiter).tryConsume(org.mockito.ArgumentMatchers.contains("ip:198.51.100.7"), anyInt(), anyInt()); + } + @Test void clientKey_authenticatedRequest_isKeyedByUserId() throws Exception { UUID userId = UUID.randomUUID(); @@ -329,6 +399,9 @@ void blocked_nonApiRequestWithHtmlAccept_redirectsInsteadOfReturningJson() throw @Test void clientKey_blankForwardedForFallsBackToRemoteAddress() throws Exception { + properties.setTrustedProxies(List.of("203.0.113.9")); + filter.initTrustedProxies(); + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 0, 60)); From a7d6e79b0b51a7b0db8460b1eac87a99a38ad9d5 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 16 Jul 2026 10:15:20 +0300 Subject: [PATCH 02/62] fix(logging): mask OpenWeatherMap API key in client error logs - Sanitized request URLs in WeatherApiClient.fetchJsonNode() before logging - Redacted the 'appid' query parameter on error paths to prevent writing the API key to application logs during parse failures or non-2xx responses --- .../service/integration/WeatherApiClient.java | 14 +++- .../ratelimit/RateLimitingFilterTest.java | 16 ++++ .../integration/WeatherApiClientTest.java | 77 ++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java index e0980b1..2fef711 100644 --- a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java +++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java @@ -14,6 +14,7 @@ import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; /** * Thin HTTP client for the OpenWeatherMap API (current weather, forecast, @@ -30,6 +31,8 @@ @Slf4j public class WeatherApiClient { + private static final Pattern APPID_PATTERN = Pattern.compile("(?i)([?&]appid=)[^&]*"); + private final RestClient restClient; private final ObjectMapper objectMapper; @@ -97,18 +100,23 @@ private JsonNode fetchJsonNode(String url) { return objectMapper.readTree(body); } catch (JsonProcessingException e) { String message = "Failed to parse JSON response from weather API service"; - log.error("{} for URL: {}", message, url, e); + log.error("{} for URL: {}", message, maskApiKey(url), e); throw new ExternalHttpCallException(message); } catch (RestClientResponseException e) { - log.error("Weather API returned {} for URL: {}", e.getStatusCode(), url, e); + log.error("Weather API returned {} for URL: {}", e.getStatusCode(), maskApiKey(url), e); throw new ExternalHttpCallException("Weather API error: " + e.getStatusCode()); } catch (Exception e) { String message = "External HTTP call failed due to network or connection issues"; - log.error("{} for URL: {}", message, url, e); + log.error("{} for URL: {}", message, maskApiKey(url), e); throw new ExternalHttpCallException(message); } } + /** Masks the {@code appid} query parameter so the API key never reaches application logs. */ + private static String maskApiKey(String url) { + return APPID_PATTERN.matcher(url).replaceAll("$1***"); + } + /** Builds a request URL for a city-name-based endpoint, with metric units and English descriptions. */ private String buildCityUrl(String suffix, String city) { return UriComponentsBuilder.fromUri(URI.create(baseApiUrl + suffix)) diff --git a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java index a104778..aad2e03 100644 --- a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java +++ b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java @@ -397,6 +397,22 @@ void blocked_nonApiRequestWithHtmlAccept_redirectsInsteadOfReturningJson() throw assertThat(response.getHeader("Location")).isEqualTo("/sign-in"); } + @Test + void clientKey_trustedProxyWithNoForwardedForHeader_fallsBackToRemoteAddress() throws Exception { + properties.setTrustedProxies(List.of("203.0.113.9")); + filter.initTrustedProxies(); + + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) + .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 0, 60)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sign-in"); + request.setRemoteAddr("203.0.113.9"); + + filter.doFilterInternal(request, new MockHttpServletResponse(), filterChain); + + verify(rateLimiter).tryConsume(contains("ip:203.0.113.9"), anyInt(), anyInt()); + } + @Test void clientKey_blankForwardedForFallsBackToRemoteAddress() throws Exception { properties.setTrustedProxies(List.of("203.0.113.9")); diff --git a/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java b/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java index 2c2fe24..eaaef2d 100644 --- a/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java +++ b/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java @@ -1,9 +1,13 @@ package com.weatherviewer.service.integration; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.weatherviewer.exception.ExternalHttpCallException; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,11 +15,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientResponseException; import java.net.URI; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -43,9 +49,13 @@ class WeatherApiClientTest { @Mock private RestClient.ResponseSpec responseSpec; + private static final String SECRET_API_KEY = "test-api-key-super-secret"; + + private ListAppender logAppender; + @BeforeEach void setUp() { - ReflectionTestUtils.setField(client, "apiKey", "test-api-key"); + ReflectionTestUtils.setField(client, "apiKey", SECRET_API_KEY); ReflectionTestUtils.setField(client, "baseApiUrl", "https://api.openweathermap.org"); ReflectionTestUtils.setField(client, "weatherApiUrlSuffix", "/data/2.5/weather"); ReflectionTestUtils.setField(client, "forecastApiUrlSuffix", "/data/2.5/forecast"); @@ -54,6 +64,15 @@ void setUp() { doReturn(requestHeadersUriSpec).when(restClient).get(); doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri(any(URI.class)); when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + + logAppender = new ListAppender<>(); + logAppender.start(); + ((Logger) LoggerFactory.getLogger(WeatherApiClient.class)).addAppender(logAppender); + } + + @AfterEach + void tearDown() { + ((Logger) LoggerFactory.getLogger(WeatherApiClient.class)).detachAppender(logAppender); } @Test @@ -158,4 +177,60 @@ void fetchJsonNode_networkException_throwsExternalHttpCallException() { .hasMessageContaining("External HTTP call failed"); } + @Test + void fetchJsonNode_jsonProcessingException_doesNotLogApiKey() throws Exception { + when(responseSpec.body(String.class)).thenReturn("invalid-json"); + when(objectMapper.readTree(anyString())) + .thenThrow(new JsonProcessingException("parse error") {}); + + assertThatThrownBy(() -> client.fetchCurrentWeatherByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class); + + assertLoggedMessagesContainNoApiKeyAndAreMasked(); + } + + @Test + void fetchJsonNode_restClientResponseException_doesNotLogApiKey() { + RestClientResponseException ex = mock(RestClientResponseException.class); + when(responseSpec.body(String.class)).thenThrow(ex); + + assertThatThrownBy(() -> client.fetchCurrentWeatherByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class); + + assertLoggedMessagesContainNoApiKeyAndAreMasked(); + } + + @Test + void fetchJsonNode_networkException_doesNotLogApiKey() { + when(responseSpec.body(String.class)) + .thenThrow(new RuntimeException("Connection refused")); + + assertThatThrownBy(() -> client.fetchCurrentWeatherByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class); + + assertLoggedMessagesContainNoApiKeyAndAreMasked(); + } + + @Test + void fetchJsonNode_errorLogging_masksApiKeyRegardlessOfSuffixOrParamOrder() { + when(responseSpec.body(String.class)) + .thenThrow(new RuntimeException("Connection refused")); + + assertThatThrownBy(() -> client.fetchCurrentWeatherByCoordinates(50.45, 30.52)) + .isInstanceOf(ExternalHttpCallException.class); + + assertLoggedMessagesContainNoApiKeyAndAreMasked(); + } + + private void assertLoggedMessagesContainNoApiKeyAndAreMasked() { + List events = logAppender.list; + assertThat(events).isNotEmpty(); + + for (ILoggingEvent event : events) { + assertThat(event.getFormattedMessage()).doesNotContain(SECRET_API_KEY); + } + + assertThat(events).anyMatch(event -> event.getFormattedMessage().contains("appid=***")); + } + } From 42c4f8293cc29ad822966db44771367010b5ec2e Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 16 Jul 2026 10:39:35 +0300 Subject: [PATCH 03/62] refactor(docker): secure container by running as non-root user --- Dockerfile | 5 ++++- docker-compose.yml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fd7532e..c1ac92a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,11 @@ RUN mvn package -DskipTests -B # Run stage FROM eclipse-temurin:17-jre-alpine WORKDIR /app +RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY --from=build /app/target/*.jar app.jar +RUN chown -R appuser:appgroup /app +USER appuser EXPOSE 8080 HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:+UseContainerSupport", "-jar", "app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index 16c2f25..7cccee4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,8 @@ services: weather_viewer: + build: + context: . + dockerfile: Dockerfile image: podllev/weather-viewer:latest container_name: weather_viewer restart: always From 7935145e23554bf8d8685544477693c95431f5f0 Mon Sep 17 00:00:00 2001 From: podlLev Date: Fri, 17 Jul 2026 11:52:13 +0300 Subject: [PATCH 04/62] security: set session cookie Secure + SameSite=Strict --- src/main/resources/application.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a158e65..5480b63 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,6 +10,9 @@ spring.jpa.open-in-view=false spring.mvc.hiddenmethod.filter.enabled=true spring.jpa.properties.hibernate.jdbc.time_zone=UTC +server.servlet.session.cookie.secure=true +server.servlet.session.cookie.same-site=strict + # --- Liquibase --- spring.liquibase.change-log=classpath:liquibase/changelog.xml spring.liquibase.enabled=true From 098e0d521f71a6dee0e8ee1db0e92141c804368d Mon Sep 17 00:00:00 2001 From: podlLev Date: Fri, 17 Jul 2026 12:09:10 +0300 Subject: [PATCH 05/62] security: cap concurrent sessions per user to 1 --- .../weatherviewer/config/SecurityConfig.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index bb8d0e9..883ca00 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -11,6 +11,8 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -80,11 +82,30 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .deleteCookies("JSESSIONID") .logoutSuccessUrl("/sign-in") ) + .sessionManagement(session -> session + .maximumSessions(1) + .maxSessionsPreventsLogin(false) + .sessionRegistry(sessionRegistry()) + .expiredUrl("/sign-in?expired") + ) .addFilterAfter(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + /** + * Tracks each principal's active sessions so + * {@link org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy} + * can enforce {@code maximumSessions(1)}. Must be a singleton bean (not + * created inline) so the same instance is shared between the security + * filter chain and HttpSessionEventPublisher, which needs it to + * remove sessions from the registry when they're invalidated or expire. + */ + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + /** Exposes Spring Security's default {@link AuthenticationManager}, used by {@link com.weatherviewer.service.LoginService}. */ @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { From dd7d4c36bb82ceccd31f671a66034ec15ce011b8 Mon Sep 17 00:00:00 2001 From: podlLev Date: Fri, 17 Jul 2026 12:38:14 +0300 Subject: [PATCH 06/62] security: add strict Content-Security-Policy header --- .../com/weatherviewer/config/SecurityConfig.java | 14 ++++++++++++++ src/main/resources/static/js/alerts.js | 7 ++++++- src/main/resources/templates/home.html | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index 883ca00..85c8a1a 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -88,6 +88,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionRegistry(sessionRegistry()) .expiredUrl("/sign-in?expired") ) + .headers(headers -> headers + .contentSecurityPolicy(csp -> csp.policyDirectives( + "default-src 'self'; " + + "script-src 'self' https://cdn.jsdelivr.net; " + + "style-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com 'unsafe-inline'; " + + "font-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com data:; " + + "img-src 'self' data:; " + + "connect-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "frame-ancestors 'none'" + )) + ) .addFilterAfter(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/resources/static/js/alerts.js b/src/main/resources/static/js/alerts.js index c2ad9f8..1d94508 100644 --- a/src/main/resources/static/js/alerts.js +++ b/src/main/resources/static/js/alerts.js @@ -6,4 +6,9 @@ document.addEventListener('DOMContentLoaded', () => { toast.show(); } }); -}); + + const sortSelect = document.getElementById('sortSelect'); + if (sortSelect) { + sortSelect.addEventListener('change', () => sortSelect.form.submit()); + } +}); \ No newline at end of file diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index d0b4086..aa00af6 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -18,7 +18,7 @@

Current Weather

- From f7dcb2ea6fd082761512347d50cd24552b533abe Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:45:21 +0300 Subject: [PATCH 07/62] feat(repository): add paginated findAll(Pageable) to UserRepository --- .../com/weatherviewer/repository/UserRepository.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/weatherviewer/repository/UserRepository.java b/src/main/java/com/weatherviewer/repository/UserRepository.java index f6b033d..f80d09a 100644 --- a/src/main/java/com/weatherviewer/repository/UserRepository.java +++ b/src/main/java/com/weatherviewer/repository/UserRepository.java @@ -1,6 +1,8 @@ package com.weatherviewer.repository; import com.weatherviewer.model.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -30,4 +32,14 @@ public interface UserRepository extends JpaRepository { @EntityGraph(attributePaths = {"locations"}) List findAll(); + /** + * Paginated counterpart to {@link #findAll()} with the same eager + * {@code locations} fetch. Used by the admin user list so it can page + * through the full user base instead of loading every account (and + * every account's locations) into memory in one response. + */ + @Override + @EntityGraph(attributePaths = {"locations"}) + Page findAll(Pageable pageable); + } From 38d440e8473b18af053459d9ade67264a9011718 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:47:06 +0300 Subject: [PATCH 08/62] feat(service): paginate UserService.getUsers() --- .../java/com/weatherviewer/service/UserService.java | 11 ++++++++--- .../weatherviewer/service/impl/UserServiceImpl.java | 7 ++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/UserService.java b/src/main/java/com/weatherviewer/service/UserService.java index 675aa89..481bdea 100644 --- a/src/main/java/com/weatherviewer/service/UserService.java +++ b/src/main/java/com/weatherviewer/service/UserService.java @@ -5,8 +5,9 @@ import com.weatherviewer.dto.UpdateUserRoleDto; import com.weatherviewer.dto.UserDto; import com.weatherviewer.model.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; -import java.util.List; import java.util.UUID; /** @@ -25,8 +26,12 @@ public interface UserService { */ UUID create(CreateUserDto createUserDto); - /** Returns every registered user account (admin use). */ - List getUsers(); + /** + * Returns registered user accounts a page at a time (admin use). + * Unbounded {@code findAll()} doesn't scale once the user base grows + * past a trivial size, so this is the only way callers can list users. + */ + Page getUsers(Pageable pageable); /** * Fetches the raw {@link User} entity by ID. diff --git a/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java index 2a02636..a69b5b9 100644 --- a/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java @@ -12,11 +12,12 @@ import com.weatherviewer.repository.UserRepository; import com.weatherviewer.service.UserService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; import java.util.UUID; /** @@ -45,8 +46,8 @@ public UUID create(CreateUserDto createUserDto) { @Override @Transactional(readOnly = true) - public List getUsers() { - return userRepository.findAll().stream().map(userMapper::toDto).toList(); + public Page getUsers(Pageable pageable) { + return userRepository.findAll(pageable).map(userMapper::toDto); } @Override From 35dea4a119aec43dbd21751fb34073078ab82e93 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:49:00 +0300 Subject: [PATCH 09/62] feat(api): expose pagination on GET /api/v1/users --- .../weatherviewer/rest/UserController.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/weatherviewer/rest/UserController.java b/src/main/java/com/weatherviewer/rest/UserController.java index 82f2264..c7ce9ec 100644 --- a/src/main/java/com/weatherviewer/rest/UserController.java +++ b/src/main/java/com/weatherviewer/rest/UserController.java @@ -7,7 +7,6 @@ import com.weatherviewer.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; @@ -18,11 +17,12 @@ import jakarta.validation.constraints.Email; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import java.util.List; import java.util.UUID; /** @@ -84,14 +84,19 @@ public UUID createUser(@RequestBody @Valid CreateUserDto createUserDto) { return userService.create(createUserDto); } - @Operation(summary = "List all users") - @ApiResponse(responseCode = "200", description = "All user accounts", - content = @Content(mediaType = "application/json", - array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))) + @Operation( + summary = "List all users, paginated", + description = "Query params: `page` (0-indexed, default 0), `size` (default 20, capped at 100), " + + "`sort` (e.g. `sort=email,asc`). Unbounded listing isn't offered - the user base is expected " + + "to grow past what's reasonable to return in one response." + ) + @ApiResponse(responseCode = "200", description = "One page of user accounts", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Page.class))) @GetMapping - public List getUsers() { - log.info("Request: getUsers called"); - return userService.getUsers(); + public Page getUsers( + @Parameter(hidden = true) Pageable pageable) { + log.info("Request: getUsers called with pageable={}", pageable); + return userService.getUsers(pageable); } @Operation(summary = "Get a user by ID") From 202056592418d1312f134bce38cd56db21536d88 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:50:51 +0300 Subject: [PATCH 10/62] feat(service): paginate LocationService.getLocations() --- .../com/weatherviewer/service/LocationService.java | 11 +++++++++-- .../service/impl/LocationServiceImpl.java | 7 ++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/LocationService.java b/src/main/java/com/weatherviewer/service/LocationService.java index 4d85f0f..65f5257 100644 --- a/src/main/java/com/weatherviewer/service/LocationService.java +++ b/src/main/java/com/weatherviewer/service/LocationService.java @@ -3,6 +3,8 @@ import com.weatherviewer.dto.AddLocationDto; import com.weatherviewer.dto.LocationDto; import com.weatherviewer.model.Location; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.UUID; @@ -21,8 +23,13 @@ public interface LocationService { */ UUID add(AddLocationDto addLocationDto); - /** Returns every saved location across all users (admin use). */ - List getLocations(); + /** + * Returns saved locations across all users a page at a time (admin + * use). Unbounded {@code findAll()} doesn't scale once the location + * count grows past a trivial size, so this is the only way callers can + * list every location system-wide. + */ + Page getLocations(Pageable pageable); /** * Fetches the raw {@link Location} entity by ID. diff --git a/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java index e5cba7d..2bac840 100644 --- a/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java @@ -8,6 +8,8 @@ import com.weatherviewer.repository.LocationRepository; import com.weatherviewer.service.LocationService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,9 +46,8 @@ private Double round(Double value) { } @Override - public List getLocations() { - List locations = locationRepository.findAll(); - return locationMapper.toDtoList(locations); + public Page getLocations(Pageable pageable) { + return locationRepository.findAll(pageable).map(locationMapper::toDto); } @Override From 7d99fffc55e4967dfe019b47918b5c128da0c054 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:52:19 +0300 Subject: [PATCH 11/62] feat(api): expose pagination on GET /api/v1/locations --- .../rest/LocationController.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/weatherviewer/rest/LocationController.java b/src/main/java/com/weatherviewer/rest/LocationController.java index 5c1d005..8067e52 100644 --- a/src/main/java/com/weatherviewer/rest/LocationController.java +++ b/src/main/java/com/weatherviewer/rest/LocationController.java @@ -21,6 +21,8 @@ import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -174,15 +176,19 @@ public UUID addLocation(@RequestBody @Valid AddLocationDto addLocationDto) { return locationService.add(addLocationDto); } - @Operation(tags = "Locations - Admin", summary = "List all locations", description = "Requires the `users:write` authority.") - @ApiResponse(responseCode = "200", description = "All saved locations across all users", - content = @Content(mediaType = "application/json", - array = @ArraySchema(schema = @Schema(implementation = LocationDto.class)))) + @Operation(tags = "Locations - Admin", + summary = "List all locations, paginated", + description = "Requires the `users:write` authority. Query params: `page` (0-indexed, default 0), " + + "`size` (default 20, capped at 100), `sort` (e.g. `sort=name,asc`). Unbounded listing isn't " + + "offered - this spans every user's saved locations system-wide." + ) + @ApiResponse(responseCode = "200", description = "One page of saved locations across all users", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Page.class))) @GetMapping @PreAuthorize("hasAuthority('users:write')") - public List getLocations() { - log.info("Request: getLocations called"); - return locationService.getLocations(); + public Page getLocations(@Parameter(hidden = true) Pageable pageable) { + log.info("Request: getLocations called with pageable={}", pageable); + return locationService.getLocations(pageable); } @Operation(tags = "Locations - Admin", summary = "Get a location by ID", description = "Requires the `users:write` authority.") From 7d7b935f6303aee018ce9cd63b523d5a2f3ef3b1 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:53:29 +0300 Subject: [PATCH 12/62] fix(config): cap max page size at 100 --- src/main/resources/application.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5480b63..718fa96 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,6 +13,10 @@ spring.jpa.properties.hibernate.jdbc.time_zone=UTC server.servlet.session.cookie.secure=true server.servlet.session.cookie.same-site=strict +spring.data.web.pageable.default-page-size=20 +spring.data.web.pageable.max-page-size=100 +spring.data.web.pageable.one-indexed-parameters=false + # --- Liquibase --- spring.liquibase.change-log=classpath:liquibase/changelog.xml spring.liquibase.enabled=true From 6848c2044c3e89d3f63e8ba5bc79a4c316428725 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:57:25 +0300 Subject: [PATCH 13/62] test(user): update UserController/UserService tests for Page response --- .../com/weatherviewer/rest/UserControllerTest.java | 8 +++++--- .../service/impl/UserServiceImplTest.java | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/weatherviewer/rest/UserControllerTest.java b/src/test/java/com/weatherviewer/rest/UserControllerTest.java index f86c40f..def1419 100644 --- a/src/test/java/com/weatherviewer/rest/UserControllerTest.java +++ b/src/test/java/com/weatherviewer/rest/UserControllerTest.java @@ -17,6 +17,8 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.test.context.support.WithMockUser; @@ -103,13 +105,13 @@ void createUser_withoutWriteAuthority_returns403() throws Exception { @Test @WithMockUser(authorities = "users:write") - void getUsers_returns200AndList() throws Exception { + void getUsers_returns200AndPage() throws Exception { List dtos = List.of(new UserDto().setEmail("john@example.com")); - when(userService.getUsers()).thenReturn(dtos); + when(userService.getUsers(any(Pageable.class))).thenReturn(new PageImpl<>(dtos)); mockMvc.perform(get("/api/v1/users")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].email").value("john@example.com")); + .andExpect(jsonPath("$.content[0].email").value("john@example.com")); } @Test diff --git a/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java index b4a27ab..4e6a245 100644 --- a/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java @@ -16,6 +16,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.List; @@ -72,15 +75,16 @@ void create_encodesPasswordAndSaves() { } @Test - void getUsers_returnsMappedList() { + void getUsers_returnsMappedPage() { UUID id = UUID.randomUUID(); User user = user(id); UserDto dto = new UserDto(); + Pageable pageable = PageRequest.of(0, 20); - when(userRepository.findAll()).thenReturn(List.of(user)); + when(userRepository.findAll(pageable)).thenReturn(new PageImpl<>(List.of(user))); when(userMapper.toDto(user)).thenReturn(dto); - assertThat(service.getUsers()).containsExactly(dto); + assertThat(service.getUsers(pageable).getContent()).containsExactly(dto); } @Test From 3f761d5e13c18b19a7338e71409972c3697b265c Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 18 Jul 2026 18:57:37 +0300 Subject: [PATCH 14/62] test(location): update LocationController/LocationService tests for Page response --- .../rest/LocationControllerTest.java | 20 +++++++++++++------ .../service/impl/LocationServiceImplTest.java | 14 ++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/test/java/com/weatherviewer/rest/LocationControllerTest.java b/src/test/java/com/weatherviewer/rest/LocationControllerTest.java index 3a442c7..11c88ba 100644 --- a/src/test/java/com/weatherviewer/rest/LocationControllerTest.java +++ b/src/test/java/com/weatherviewer/rest/LocationControllerTest.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -20,11 +22,17 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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; @WebMvcTest(LocationController.class) @WithMockUser @@ -159,13 +167,13 @@ void addLocation_returns201AndId() throws Exception { } @Test - void getLocations_returns200AndList() throws Exception { + void getLocations_returns200AndPage() throws Exception { List dtos = List.of(new LocationDto().setName("Kyiv")); - when(locationService.getLocations()).thenReturn(dtos); + when(locationService.getLocations(any(Pageable.class))).thenReturn(new PageImpl<>(dtos)); mockMvc.perform(get("/api/v1/locations")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("Kyiv")); + .andExpect(jsonPath("$.content[0].name").value("Kyiv")); } @Test diff --git a/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java index 804fe8c..d8dcad5 100644 --- a/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java @@ -12,6 +12,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.security.access.AccessDeniedException; import java.util.ArrayList; @@ -102,13 +105,14 @@ void add_nullCoordinates_doesNotThrow() { @Test void getLocations_returnsAllMapped() { - List locations = List.of(new Location()); - List dtos = List.of(new LocationDto()); + Location location = new Location(); + LocationDto dto = new LocationDto(); + Pageable pageable = PageRequest.of(0, 20); - when(locationRepository.findAll()).thenReturn(locations); - when(locationMapper.toDtoList(locations)).thenReturn(dtos); + when(locationRepository.findAll(pageable)).thenReturn(new PageImpl<>(List.of(location))); + when(locationMapper.toDto(location)).thenReturn(dto); - assertThat(service.getLocations()).isEqualTo(dtos); + assertThat(service.getLocations(pageable).getContent()).containsExactly(dto); } @Test From 22039d0cf4587da368bc80204743fba112814a32 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:04:46 +0300 Subject: [PATCH 15/62] feat(db): add units column for user unit preference - add `units` column to the users table via Liquibase changeset - default existing rows to metric --- src/main/resources/liquibase/changelog.xml | 1 + .../liquibase/update/04-add-user-units.xml | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/main/resources/liquibase/update/04-add-user-units.xml diff --git a/src/main/resources/liquibase/changelog.xml b/src/main/resources/liquibase/changelog.xml index ef04ea3..6fbc898 100644 --- a/src/main/resources/liquibase/changelog.xml +++ b/src/main/resources/liquibase/changelog.xml @@ -6,5 +6,6 @@ + diff --git a/src/main/resources/liquibase/update/04-add-user-units.xml b/src/main/resources/liquibase/update/04-add-user-units.xml new file mode 100644 index 0000000..665c82b --- /dev/null +++ b/src/main/resources/liquibase/update/04-add-user-units.xml @@ -0,0 +1,18 @@ + + + + + + CREATE TYPE unit_system AS ENUM ('METRIC','IMPERIAL'); + + + + + + ALTER TABLE users ADD COLUMN units unit_system NOT NULL DEFAULT 'METRIC'; + + + + From 1b4975947e774780ed135a8eb1986a5777399aa4 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:05:21 +0300 Subject: [PATCH 16/62] feat(model): add UnitSystem enum and units field to User --- .../notfound/UnitSystemNotFoundException.java | 13 +++++++ .../java/com/weatherviewer/model/User.java | 7 ++++ .../weatherviewer/model/enums/UnitSystem.java | 36 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/main/java/com/weatherviewer/exception/notfound/UnitSystemNotFoundException.java create mode 100644 src/main/java/com/weatherviewer/model/enums/UnitSystem.java diff --git a/src/main/java/com/weatherviewer/exception/notfound/UnitSystemNotFoundException.java b/src/main/java/com/weatherviewer/exception/notfound/UnitSystemNotFoundException.java new file mode 100644 index 0000000..4f72da7 --- /dev/null +++ b/src/main/java/com/weatherviewer/exception/notfound/UnitSystemNotFoundException.java @@ -0,0 +1,13 @@ +package com.weatherviewer.exception.notfound; + +/** + * Thrown when a unit system name cannot be matched to a known + * {@link com.weatherviewer.model.enums.UnitSystem}. + */ +public class UnitSystemNotFoundException extends NotFoundException { + + public UnitSystemNotFoundException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/weatherviewer/model/User.java b/src/main/java/com/weatherviewer/model/User.java index 7221f16..24b87f7 100644 --- a/src/main/java/com/weatherviewer/model/User.java +++ b/src/main/java/com/weatherviewer/model/User.java @@ -1,6 +1,7 @@ package com.weatherviewer.model; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import jakarta.persistence.*; import lombok.Getter; @@ -50,6 +51,12 @@ public class User extends BaseEntity { @JdbcType(PostgreSQLEnumJdbcType.class) private Role role; + /** Preferred unit system (metric/imperial) for displaying weather data. Defaults to {@code METRIC}. */ + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "unit_system") + @JdbcType(PostgreSQLEnumJdbcType.class) + private UnitSystem units = UnitSystem.METRIC; + /** Locations saved by this user; removed automatically if the user is deleted. */ @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List locations; diff --git a/src/main/java/com/weatherviewer/model/enums/UnitSystem.java b/src/main/java/com/weatherviewer/model/enums/UnitSystem.java new file mode 100644 index 0000000..aeb8587 --- /dev/null +++ b/src/main/java/com/weatherviewer/model/enums/UnitSystem.java @@ -0,0 +1,36 @@ +package com.weatherviewer.model.enums; + +import com.weatherviewer.exception.notfound.UnitSystemNotFoundException; + +/** + * A user's preferred unit system for displaying weather data. + *

+ * Weather is always fetched from the provider and cached in metric units + * (see {@code WeatherApiClient}); conversion to the user's preferred + * system happens at render time via {@code UnitConverter}, so switching a + * user's preference never invalidates the shared weather cache. + */ +public enum UnitSystem { + + /** Celsius, meters/second. */ + METRIC, + /** Fahrenheit, miles/hour. */ + IMPERIAL; + + /** + * Resolves a unit system by name, case-insensitively. + * + * @param name the unit system name to look up (e.g. {@code "imperial"}) + * @return the matching {@link UnitSystem} + * @throws UnitSystemNotFoundException if no unit system matches the given name + */ + public static UnitSystem getInstance(String name) { + for (UnitSystem unitSystem : values()) { + if (unitSystem.name().equalsIgnoreCase(name)) { + return unitSystem; + } + } + throw new UnitSystemNotFoundException("Unit system not found by name: " + name); + } + +} From 106c2cb0c7b8c80c9beb6b9c499673a3def5eb01 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:05:38 +0300 Subject: [PATCH 17/62] feat(dto): expose units on user-facing DTOs --- src/main/java/com/weatherviewer/dto/UpdateUserDto.java | 3 +++ src/main/java/com/weatherviewer/dto/UserDto.java | 5 +++++ src/test/java/com/weatherviewer/dto/UserDtoTest.java | 2 ++ 3 files changed, 10 insertions(+) diff --git a/src/main/java/com/weatherviewer/dto/UpdateUserDto.java b/src/main/java/com/weatherviewer/dto/UpdateUserDto.java index 412a539..789c529 100644 --- a/src/main/java/com/weatherviewer/dto/UpdateUserDto.java +++ b/src/main/java/com/weatherviewer/dto/UpdateUserDto.java @@ -39,4 +39,7 @@ public class UpdateUserDto { @ToString.Exclude private String password; + @Schema(description = "Preferred unit system for displaying weather (\"METRIC\" or \"IMPERIAL\"). Omit to keep the current preference.", example = "IMPERIAL") + private String units; + } diff --git a/src/main/java/com/weatherviewer/dto/UserDto.java b/src/main/java/com/weatherviewer/dto/UserDto.java index a0efb56..f826587 100644 --- a/src/main/java/com/weatherviewer/dto/UserDto.java +++ b/src/main/java/com/weatherviewer/dto/UserDto.java @@ -1,6 +1,7 @@ package com.weatherviewer.dto; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; @@ -46,6 +47,10 @@ public class UserDto { @NotNull(message = "Role cannot be null") private Role role; + @Schema(description = "Preferred unit system for displaying weather") + @NotNull(message = "Units cannot be null") + private UnitSystem units; + @Schema(description = "Locations saved by this user") @NotNull(message = "Locations list cannot be null") private List locations; diff --git a/src/test/java/com/weatherviewer/dto/UserDtoTest.java b/src/test/java/com/weatherviewer/dto/UserDtoTest.java index e00cf48..e963d10 100644 --- a/src/test/java/com/weatherviewer/dto/UserDtoTest.java +++ b/src/test/java/com/weatherviewer/dto/UserDtoTest.java @@ -1,6 +1,7 @@ package com.weatherviewer.dto; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import jakarta.validation.Validator; import org.junit.jupiter.api.BeforeEach; @@ -28,6 +29,7 @@ private UserDto validDto() { .setEmail("john@example.com") .setStatus(UserStatus.ACTIVE) .setRole(Role.USER) + .setUnits(UnitSystem.METRIC) .setLocations(List.of()) .setCreatedAt(LocalDateTime.now()); } From 5ad6cbeaedbe53231e7d781e333764894084c829 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:05:55 +0300 Subject: [PATCH 18/62] feat(service): persist user's unit preference on profile update --- .../service/impl/UserServiceImpl.java | 5 + .../service/impl/UserServiceImplTest.java | 91 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java index a69b5b9..83d7598 100644 --- a/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java @@ -9,6 +9,7 @@ import com.weatherviewer.mapper.UserMapper; import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.service.UserService; import lombok.RequiredArgsConstructor; @@ -101,6 +102,10 @@ && existsByEmail(updateUserDto.getEmail())) { user.setPassword(passwordEncoder.encode(updateUserDto.getPassword())); } + if (updateUserDto.getUnits() != null && !updateUserDto.getUnits().isBlank()) { + user.setUnits(UnitSystem.getInstance(updateUserDto.getUnits())); + } + userRepository.save(user); return userMapper.toDto(user); } diff --git a/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java index 4e6a245..7523c8c 100644 --- a/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/UserServiceImplTest.java @@ -5,10 +5,12 @@ import com.weatherviewer.dto.UpdateUserRoleDto; import com.weatherviewer.dto.UserDto; import com.weatherviewer.exception.EmailAlreadyInUseException; +import com.weatherviewer.exception.notfound.UnitSystemNotFoundException; import com.weatherviewer.exception.notfound.UserNotFoundException; import com.weatherviewer.mapper.UserMapper; import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import org.junit.jupiter.api.Test; @@ -29,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class UserServiceImplTest { @@ -244,6 +247,94 @@ void update_blankPassword_doesNotEncodePassword() { verify(passwordEncoder, never()).encode(any()); } + @Test + void update_withUnits_updatesUnits() { + UUID id = UUID.randomUUID(); + User user = user(id); + UpdateUserDto dto = new UpdateUserDto() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setUnits("IMPERIAL"); + + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(userMapper.toDto(user)).thenReturn(new UserDto()); + + service.update(id, dto); + + assertThat(user.getUnits()).isEqualTo(UnitSystem.IMPERIAL); + } + + @Test + void update_withUnitsLowercase_isCaseInsensitive() { + UUID id = UUID.randomUUID(); + User user = user(id); + UpdateUserDto dto = new UpdateUserDto() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setUnits("imperial"); + + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(userMapper.toDto(user)).thenReturn(new UserDto()); + + service.update(id, dto); + + assertThat(user.getUnits()).isEqualTo(UnitSystem.IMPERIAL); + } + + @Test + void update_nullUnits_leavesExistingUnitsUnchanged() { + UUID id = UUID.randomUUID(); + User user = user(id).setUnits(UnitSystem.METRIC); + UpdateUserDto dto = new UpdateUserDto() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setUnits(null); + + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(userMapper.toDto(user)).thenReturn(new UserDto()); + + service.update(id, dto); + + assertThat(user.getUnits()).isEqualTo(UnitSystem.METRIC); + } + + @Test + void update_blankUnits_leavesExistingUnitsUnchanged() { + UUID id = UUID.randomUUID(); + User user = user(id).setUnits(UnitSystem.METRIC); + UpdateUserDto dto = new UpdateUserDto() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setUnits(" "); + + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(userMapper.toDto(user)).thenReturn(new UserDto()); + + service.update(id, dto); + + assertThat(user.getUnits()).isEqualTo(UnitSystem.METRIC); + } + + @Test + void update_invalidUnits_throwsUnitSystemNotFoundException() { + UUID id = UUID.randomUUID(); + User user = user(id); + UpdateUserDto dto = new UpdateUserDto() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setUnits("KELVIN"); + + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.update(id, dto)) + .isInstanceOf(UnitSystemNotFoundException.class); + } + @Test void existsByEmail_returnsTrue() { when(userRepository.existsByEmail("john@example.com")).thenReturn(true); From 648583c4f146746ea42805aab898fdbb4e053f81 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:06:10 +0300 Subject: [PATCH 19/62] feat(weather): convert weather values to the user's preferred units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add °C→°F and m/s→mph conversion at the presentation boundary - round to one decimal place, half-up, so values don't display raw floating-point artifacts --- .../service/helper/UnitConverter.java | 96 ++++++++ .../service/helper/UnitConverterTest.java | 224 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 src/main/java/com/weatherviewer/service/helper/UnitConverter.java create mode 100644 src/test/java/com/weatherviewer/service/helper/UnitConverterTest.java diff --git a/src/main/java/com/weatherviewer/service/helper/UnitConverter.java b/src/main/java/com/weatherviewer/service/helper/UnitConverter.java new file mode 100644 index 0000000..92f6e99 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/helper/UnitConverter.java @@ -0,0 +1,96 @@ +package com.weatherviewer.service.helper; + +import com.weatherviewer.dto.WeatherDto; +import com.weatherviewer.model.enums.UnitSystem; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; + +/** + * Converts {@link WeatherDto} values from the canonical metric units used + * by {@code WeatherApiClient} and its cache into a user's preferred + * display units. + *

+ * Conversion happens here, at the presentation boundary, rather than by + * requesting a different unit system from the provider per user. That + * keeps a single shared cache entry per city/coordinate pair usable by + * every user regardless of their preference, instead of doubling cache + * entries (and provider requests) for every location. + */ +@Component +public class UnitConverter { + + private static final double CELSIUS_TO_FAHRENHEIT_MULTIPLIER = 9.0 / 5.0; + private static final double CELSIUS_TO_FAHRENHEIT_OFFSET = 32.0; + private static final double METERS_PER_SECOND_TO_MPH = 2.2369362921; + + /** + * Returns {@code weather} unchanged if {@code units} is {@link UnitSystem#METRIC} + * (the storage/cache unit), or a converted copy if {@link UnitSystem#IMPERIAL}. + * The original {@code weather} instance is never mutated, since it may + * be a shared object backed by the cache. + */ + public WeatherDto toDisplayUnits(WeatherDto weather, UnitSystem units) { + if (weather == null || units == UnitSystem.METRIC) { + return weather; + } + + return new WeatherDto() + .setWeatherCondition(weather.getWeatherCondition()) + .setTimeOfDay(weather.getTimeOfDay()) + .setDescription(weather.getDescription()) + .setTemperature(toFahrenheit(weather.getTemperature())) + .setTemperatureFeelsLike(toFahrenheit(weather.getTemperatureFeelsLike())) + .setTemperatureMinimum(toFahrenheit(weather.getTemperatureMinimum())) + .setTemperatureMaximum(toFahrenheit(weather.getTemperatureMaximum())) + .setHumidity(weather.getHumidity()) + .setPressure(weather.getPressure()) + .setWindSpeed(toMph(weather.getWindSpeed())) + .setWindDirection(weather.getWindDirection()) + .setWindGust(toMph(weather.getWindGust())) + .setCloudiness(weather.getCloudiness()) + .setDate(weather.getDate()) + .setSunrise(weather.getSunrise()) + .setSunset(weather.getSunset()); + } + + /** Applies {@link #toDisplayUnits(WeatherDto, UnitSystem)} to every entry in a list. */ + public List toDisplayUnits(List weatherList, UnitSystem units) { + if (weatherList == null || units == UnitSystem.METRIC) { + return weatherList; + } + return weatherList.stream().map(w -> toDisplayUnits(w, units)).toList(); + } + + /** The temperature unit symbol to show alongside converted values ({@code "°C"} or {@code "°F"}). */ + public String temperatureSymbol(UnitSystem units) { + return units == UnitSystem.IMPERIAL ? "°F" : "°C"; + } + + /** The wind speed unit label to show alongside converted values ({@code "m/s"} or {@code "mph"}). */ + public String windSpeedUnit(UnitSystem units) { + return units == UnitSystem.IMPERIAL ? "mph" : "m/s"; + } + + private Double toFahrenheit(Double celsius) { + return celsius == null ? null : round((celsius * CELSIUS_TO_FAHRENHEIT_MULTIPLIER) + CELSIUS_TO_FAHRENHEIT_OFFSET); + } + + private Double toMph(Double metersPerSecond) { + return metersPerSecond == null ? null : round(metersPerSecond * METERS_PER_SECOND_TO_MPH); + } + + /** + * Rounds to one decimal place using half-up rounding (e.g. {@code 71.249999...} + * becomes {@code 71.2}, not a repeating-decimal display artifact of the raw + * multiplication). Goes through {@link BigDecimal} rather than + * {@code Math.round(x * 10) / 10.0} to avoid the binary floating-point + * representation error that approach can introduce at the tenths place. + */ + private Double round(double value) { + return BigDecimal.valueOf(value).setScale(1, RoundingMode.HALF_UP).doubleValue(); + } + +} diff --git a/src/test/java/com/weatherviewer/service/helper/UnitConverterTest.java b/src/test/java/com/weatherviewer/service/helper/UnitConverterTest.java new file mode 100644 index 0000000..da33c75 --- /dev/null +++ b/src/test/java/com/weatherviewer/service/helper/UnitConverterTest.java @@ -0,0 +1,224 @@ +package com.weatherviewer.service.helper; + +import com.weatherviewer.dto.WeatherDto; +import com.weatherviewer.dto.enums.TimeOfDay; +import com.weatherviewer.dto.enums.WeatherCondition; +import com.weatherviewer.model.enums.UnitSystem; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnitConverterTest { + + private final UnitConverter converter = new UnitConverter(); + + private static final Date NOW = new Date(); + + private WeatherDto metricWeather(double temperature, double feelsLike, double min, double max, + double windSpeed, double windGust) { + return new WeatherDto() + .setWeatherCondition(WeatherCondition.CLEAR) + .setTimeOfDay(TimeOfDay.DAY) + .setDescription("clear sky") + .setTemperature(temperature) + .setTemperatureFeelsLike(feelsLike) + .setTemperatureMinimum(min) + .setTemperatureMaximum(max) + .setHumidity(50) + .setPressure(1013) + .setWindSpeed(windSpeed) + .setWindDirection(180) + .setWindGust(windGust) + .setCloudiness(20) + .setDate(NOW); + } + + @Test + void toDisplayUnits_metric_returnsSameInstanceUnchanged() { + WeatherDto weather = metricWeather(20.0, 19.0, 15.0, 25.0, 5.0, 8.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.METRIC); + + assertThat(result).isSameAs(weather); + } + + @Test + void toDisplayUnits_nullWeather_returnsNull() { + assertThat(converter.toDisplayUnits((WeatherDto) null, UnitSystem.IMPERIAL)).isNull(); + } + + @Test + void toDisplayUnits_imperial_convertsTemperatureToFahrenheit() { + WeatherDto weather = metricWeather(0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getTemperature()).isEqualTo(32.0); + } + + @Test + void toDisplayUnits_imperial_convertsHundredCelsiusCorrectly() { + WeatherDto weather = metricWeather(100.0, 100.0, 100.0, 100.0, 0.0, 0.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getTemperature()).isEqualTo(212.0); + } + + @Test + void toDisplayUnits_imperial_convertsFeelsLikeMinAndMaxTemperatures() { + WeatherDto weather = metricWeather(20.0, 18.5, 15.0, 25.0, 0.0, 0.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getTemperatureFeelsLike()).isEqualTo(65.3); + assertThat(result.getTemperatureMinimum()).isEqualTo(59.0); + assertThat(result.getTemperatureMaximum()).isEqualTo(77.0); + } + + @Test + void toDisplayUnits_imperial_convertsWindSpeedAndGustToMph() { + WeatherDto weather = metricWeather(0.0, 0.0, 0.0, 0.0, 5.0, 10.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getWindSpeed()).isEqualTo(11.2); + assertThat(result.getWindGust()).isEqualTo(22.4); + } + + @Test + void toDisplayUnits_imperial_roundsTemperatureToOneDecimalPlace() { + WeatherDto weather = metricWeather(18.5, 0.0, 0.0, 0.0, 0.0, 0.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getTemperature()).isEqualTo(65.3); + } + + @Test + void toDisplayUnits_imperial_roundsHalfUp() { + WeatherDto weather = metricWeather(0.05, 0.0, 0.0, 0.0, 0.0, 0.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getTemperature()).isEqualTo(32.1); + } + + @Test + void toDisplayUnits_imperial_windSpeedRoundedToOneDecimalPlace() { + WeatherDto weather = metricWeather(0.0, 0.0, 0.0, 0.0, 3.6, 0.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getWindSpeed()).isEqualTo(8.1); + } + + @Test + void toDisplayUnits_imperial_nullMinMaxTemperature_staysNull() { + WeatherDto weather = metricWeather(20.0, 19.0, 15.0, 25.0, 5.0, 8.0) + .setTemperatureMinimum(null) + .setTemperatureMaximum(null); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getTemperatureMinimum()).isNull(); + assertThat(result.getTemperatureMaximum()).isNull(); + } + + @Test + void toDisplayUnits_imperial_nullWindGust_staysNull() { + WeatherDto weather = metricWeather(20.0, 19.0, 15.0, 25.0, 5.0, 8.0) + .setWindGust(null); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getWindGust()).isNull(); + } + + @Test + void toDisplayUnits_imperial_doesNotMutateOriginalInstance() { + WeatherDto weather = metricWeather(20.0, 19.0, 15.0, 25.0, 5.0, 8.0); + + converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(weather.getTemperature()).isEqualTo(20.0); + assertThat(weather.getWindSpeed()).isEqualTo(5.0); + } + + @Test + void toDisplayUnits_imperial_returnsDifferentInstance() { + WeatherDto weather = metricWeather(20.0, 19.0, 15.0, 25.0, 5.0, 8.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result).isNotSameAs(weather); + } + + @Test + void toDisplayUnits_imperial_preservesUnitIndependentFields() { + WeatherDto weather = metricWeather(20.0, 19.0, 15.0, 25.0, 5.0, 8.0); + + WeatherDto result = converter.toDisplayUnits(weather, UnitSystem.IMPERIAL); + + assertThat(result.getWeatherCondition()).isEqualTo(WeatherCondition.CLEAR); + assertThat(result.getTimeOfDay()).isEqualTo(TimeOfDay.DAY); + assertThat(result.getDescription()).isEqualTo("clear sky"); + assertThat(result.getHumidity()).isEqualTo(50); + assertThat(result.getPressure()).isEqualTo(1013); + assertThat(result.getWindDirection()).isEqualTo(180); + assertThat(result.getCloudiness()).isEqualTo(20); + assertThat(result.getDate()).isEqualTo(NOW); + } + + @Test + void toDisplayUnits_listMetric_returnsSameListUnchanged() { + List list = List.of(metricWeather(20.0, 19.0, 15.0, 25.0, 5.0, 8.0)); + + List result = converter.toDisplayUnits(list, UnitSystem.METRIC); + + assertThat(result).isSameAs(list); + } + + @Test + void toDisplayUnits_nullList_returnsNull() { + assertThat(converter.toDisplayUnits((List) null, UnitSystem.IMPERIAL)).isNull(); + } + + @Test + void toDisplayUnits_listImperial_convertsEveryEntry() { + List list = List.of( + metricWeather(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + metricWeather(100.0, 0.0, 0.0, 0.0, 0.0, 0.0) + ); + + List result = converter.toDisplayUnits(list, UnitSystem.IMPERIAL); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getTemperature()).isEqualTo(32.0); + assertThat(result.get(1).getTemperature()).isEqualTo(212.0); + } + + @Test + void temperatureSymbol_metric_returnsCelsiusSymbol() { + assertThat(converter.temperatureSymbol(UnitSystem.METRIC)).isEqualTo("°C"); + } + + @Test + void temperatureSymbol_imperial_returnsFahrenheitSymbol() { + assertThat(converter.temperatureSymbol(UnitSystem.IMPERIAL)).isEqualTo("°F"); + } + + @Test + void windSpeedUnit_metric_returnsMetersPerSecond() { + assertThat(converter.windSpeedUnit(UnitSystem.METRIC)).isEqualTo("m/s"); + } + + @Test + void windSpeedUnit_imperial_returnsMph() { + assertThat(converter.windSpeedUnit(UnitSystem.IMPERIAL)).isEqualTo("mph"); + } + +} From 5d6d8da77979a9baddb1023278f969ccd07a441e Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:08:17 +0300 Subject: [PATCH 20/62] feat(web): render weather in the signed-in user's preferred units --- .../controller/ForecastController.java | 10 +++++-- .../controller/HomeController.java | 7 ++++- src/main/resources/templates/forecast.html | 6 ++--- src/main/resources/templates/home.html | 10 +++---- .../controller/ForecastControllerTest.java | 26 ++++++++++++++++++- .../controller/HomeControllerTest.java | 4 ++- .../integration/ForecastIntegrationTest.java | 4 ++- 7 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/weatherviewer/controller/ForecastController.java b/src/main/java/com/weatherviewer/controller/ForecastController.java index eeb7f6f..c8cae59 100644 --- a/src/main/java/com/weatherviewer/controller/ForecastController.java +++ b/src/main/java/com/weatherviewer/controller/ForecastController.java @@ -4,6 +4,7 @@ import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.service.helper.UnitConverter; import com.weatherviewer.validation.annotation.Latitude; import com.weatherviewer.validation.annotation.Longitude; import lombok.RequiredArgsConstructor; @@ -27,6 +28,7 @@ public class ForecastController { private final WeatherApiService weatherApiService; private final LocationService locationService; + private final UnitConverter unitConverter; /** * Renders the forecast page for the saved location at the given @@ -42,8 +44,10 @@ public String getForecast(@RequestParam("lat") @Latitude double latitude, log.info("Fetching forecast for user={} at lat={}, lon={}", user.getUsername(), latitude, longitude); String locationName = locationService.getByCoordinatesAndUserId(latitude, longitude, user.getId()).getName(); - List hourlyForecast = weatherApiService.getHourlyForecastByCoordinates(latitude, longitude); - List dailyForecast = weatherApiService.getDailyForecastByCoordinates(latitude, longitude); + List hourlyForecast = unitConverter.toDisplayUnits( + weatherApiService.getHourlyForecastByCoordinates(latitude, longitude), user.getUnits()); + List dailyForecast = unitConverter.toDisplayUnits( + weatherApiService.getDailyForecastByCoordinates(latitude, longitude), user.getUnits()); log.info("Forecast retrieved for location={} (user={})", locationName, user.getUsername()); @@ -52,6 +56,8 @@ public String getForecast(@RequestParam("lat") @Latitude double latitude, model.addAttribute("dailyForecast", dailyForecast); model.addAttribute("login", user.getFullName()); + model.addAttribute("temperatureSymbol", unitConverter.temperatureSymbol(user.getUnits())); + model.addAttribute("windSpeedUnit", unitConverter.windSpeedUnit(user.getUnits())); return "forecast"; } diff --git a/src/main/java/com/weatherviewer/controller/HomeController.java b/src/main/java/com/weatherviewer/controller/HomeController.java index 37c253b..04c271c 100644 --- a/src/main/java/com/weatherviewer/controller/HomeController.java +++ b/src/main/java/com/weatherviewer/controller/HomeController.java @@ -5,6 +5,7 @@ import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.service.helper.UnitConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -32,6 +33,7 @@ public class HomeController { private final WeatherApiService weatherApiService; private final LocationService locationService; + private final UnitConverter unitConverter; /** * Renders the dashboard. Locations are fetched pre-sorted by @@ -57,7 +59,8 @@ public String home(Model model, @AuthenticationPrincipal SecUser user, Map locationWeatherMap = userLocations.parallelStream() .collect(Collectors.toMap( Function.identity(), - weatherApiService::getWeatherByLocation, + location -> unitConverter.toDisplayUnits( + weatherApiService.getWeatherByLocation(location), user.getUnits()), (existing, replacement) -> existing, LinkedHashMap::new )); @@ -67,6 +70,8 @@ public String home(Model model, @AuthenticationPrincipal SecUser user, model.addAttribute("login", user.getFullName()); model.addAttribute("sort", sort); + model.addAttribute("temperatureSymbol", unitConverter.temperatureSymbol(user.getUnits())); + model.addAttribute("windSpeedUnit", unitConverter.windSpeedUnit(user.getUnits())); return "home"; } diff --git a/src/main/resources/templates/forecast.html b/src/main/resources/templates/forecast.html index 9ca3306..4617e0f 100644 --- a/src/main/resources/templates/forecast.html +++ b/src/main/resources/templates/forecast.html @@ -20,7 +20,7 @@

Hourly Forecast

-

+

@@ -40,8 +40,8 @@

-

-

+

+

diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index aa00af6..00c6516 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -97,8 +97,8 @@

Location

th:src="@{'/images/' + ${item.value.weatherCondition} + '-' + ${item.value.timeOfDay} + '.svg'}">
-

-

+

+

@@ -112,7 +112,7 @@

  • - +
  • @@ -120,7 +120,7 @@

  • - +
    • - +
    • diff --git a/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java b/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java index c13a398..26052a3 100644 --- a/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java @@ -4,9 +4,12 @@ import com.weatherviewer.dto.WeatherDto; import com.weatherviewer.dto.enums.TimeOfDay; import com.weatherviewer.dto.enums.WeatherCondition; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.service.helper.UnitConverter; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -19,6 +22,8 @@ import java.util.Set; import java.util.UUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -36,6 +41,24 @@ class ForecastControllerTest { @MockitoBean LocationService locationService; + @MockitoBean + UnitConverter unitConverter; + + /** + * UnitConverter is real (unmocked) application logic in production; + * here it's stubbed to pass values through unchanged, since this test + * only needs to confirm ForecastController plumbs data into the model + * correctly, not that unit conversion math is right (that's covered + * separately). Without this, the mock would return null and break the + * existing hourlyForecast/dailyForecast assertions below. + */ + @BeforeEach + void stubUnitConverterPassThrough() { + when(unitConverter.toDisplayUnits(anyList(), any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(unitConverter.temperatureSymbol(any())).thenReturn("°C"); + when(unitConverter.windSpeedUnit(any())).thenReturn("m/s"); + } + private SecUser secUser() { return new SecUser( UUID.randomUUID(), @@ -43,7 +66,8 @@ private SecUser secUser() { "hashed", Set.of(), true, - "John Doe" + "John Doe", + UnitSystem.METRIC ); } diff --git a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java b/src/test/java/com/weatherviewer/controller/HomeControllerTest.java index 0e5486d..f1c0e97 100644 --- a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/HomeControllerTest.java @@ -4,6 +4,7 @@ import com.weatherviewer.dto.WeatherDto; import com.weatherviewer.dto.enums.TimeOfDay; import com.weatherviewer.dto.enums.WeatherCondition; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; @@ -46,7 +47,8 @@ private SecUser secUser() { "hashed", Set.of(), true, - "John Doe" + "John Doe", + UnitSystem.METRIC ); } diff --git a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java b/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java index 576456d..8178220 100644 --- a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java @@ -2,6 +2,7 @@ import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.LocationRepository; import com.weatherviewer.repository.UserRepository; @@ -56,7 +57,8 @@ void setUp() { savedUser.getPassword(), Set.of(), true, - savedUser.getFullName() + savedUser.getFullName(), + UnitSystem.METRIC ); } From a97df871d3b9ffb4ee275f40e45063b70a618288 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:08:35 +0300 Subject: [PATCH 21/62] feat(profile): add unit system selector to the profile page --- .../controller/ProfileController.java | 55 +++++++++++++++++++ src/main/resources/templates/profile.html | 8 +++ .../controller/ProfileControllerTest.java | 5 +- .../integration/ProfileIntegrationTest.java | 4 +- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/weatherviewer/controller/ProfileController.java b/src/main/java/com/weatherviewer/controller/ProfileController.java index 8b7a4d0..8767ca0 100644 --- a/src/main/java/com/weatherviewer/controller/ProfileController.java +++ b/src/main/java/com/weatherviewer/controller/ProfileController.java @@ -4,10 +4,18 @@ import com.weatherviewer.model.User; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -16,6 +24,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import java.util.UUID; + /** * Thymeleaf controller for the current user's account profile page: * displaying stored profile fields and applying edits to them. @@ -27,6 +37,20 @@ public class ProfileController { private final UserService userService; + /** + * Spring Security 6 defaults to "explicit save" mode: {@code SecurityContextHolderFilter} + * loads the session's context at the start of a request but no longer writes + * {@link SecurityContextHolder}'s context back to the session automatically at + * the end of one. Mutating {@link SecurityContextHolder} alone (as earlier + * versions of this code did) therefore only lasted for the remainder of the + * current request — the next request would reload the untouched, stale + * session copy. This repository lets {@link #refreshAuthenticatedPrincipal} + * write the refreshed context back into the session explicitly, the same + * place {@link com.weatherviewer.security.UserDetailsServiceImpl}-backed + * login already stores it, so it's actually still there on the next request. + */ + private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); + /** Renders the profile page, pre-populated with the signed-in user's current details. */ @GetMapping("/profile") public String getProfile(@AuthenticationPrincipal SecUser user, Model model) { @@ -48,6 +72,7 @@ public String getProfile(@AuthenticationPrincipal SecUser user, Model model) { public String updateProfile(@Valid @ModelAttribute("user") UpdateUserDto dto, BindingResult bindingResult, @AuthenticationPrincipal SecUser user, + HttpServletRequest request, HttpServletResponse response, RedirectAttributes redirectAttributes, Model model) { if (bindingResult.hasErrors()) { User userEntity = userService.getEntityById(user.getId()); @@ -56,8 +81,38 @@ public String updateProfile(@Valid @ModelAttribute("user") UpdateUserDto dto, } userService.update(user.getId(), dto); + refreshAuthenticatedPrincipal(user.getId(), request, response); redirectAttributes.addFlashAttribute("successMessage", "Profile updated successfully!"); return "redirect:/profile"; } + /** + * Replaces the session's authenticated principal with a fresh {@link SecUser} + * built from the just-saved entity. + *

      + * {@code @AuthenticationPrincipal SecUser} is the same immutable object that + * was built once at sign-in and stored in the session's {@code SecurityContext} + * ({@link com.weatherviewer.security.UserDetailsServiceImpl}). Saving profile + * changes to the database does not, by itself, update that cached principal - + * every other controller reading {@code user.getUnits()} (or full name, email, + * etc.) off {@code @AuthenticationPrincipal} would keep seeing the pre-update + * values for the rest of the session, e.g. the dashboard silently continuing + * to render in the old unit system after switching Celsius/Fahrenheit until + * the user logs out and back in. Rebuilding and re-setting the principal here + * makes the change visible immediately, on the very next request. + */ + private void refreshAuthenticatedPrincipal(UUID userId, HttpServletRequest request, HttpServletResponse response) { + User updatedEntity = userService.getEntityById(userId); + SecUser refreshedPrincipal = SecUser.fromUser(updatedEntity); + + Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication(); + Authentication refreshedAuth = new UsernamePasswordAuthenticationToken( + refreshedPrincipal, currentAuth.getCredentials(), refreshedPrincipal.getAuthorities()); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(refreshedAuth); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, request, response); + } + } diff --git a/src/main/resources/templates/profile.html b/src/main/resources/templates/profile.html index cbe5818..8273d40 100644 --- a/src/main/resources/templates/profile.html +++ b/src/main/resources/templates/profile.html @@ -43,6 +43,14 @@

      Account Settings

      +
      + + +
      +
      diff --git a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java b/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java index 9b4268f..4339257 100644 --- a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java @@ -2,6 +2,7 @@ import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.UserService; @@ -50,7 +51,8 @@ private SecUser secUser() { "hashed", Set.of(), true, - "John Doe" + "John Doe", + UnitSystem.METRIC ); } @@ -94,6 +96,7 @@ void getProfile_addsFullNameToModel() throws Exception { @Test void updateProfile_success_redirectsToProfile() throws Exception { SecUser user = secUser(); + when(userService.getEntityById(user.getId())).thenReturn(userEntity(user.getId())); mockMvc.perform(post("/profile") .with(user(user)) diff --git a/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java b/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java index 6da302c..b8d4406 100644 --- a/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java @@ -2,6 +2,7 @@ import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.security.SecUser; @@ -55,7 +56,8 @@ void setUp() { savedUser.getPassword(), Set.of(), true, - savedUser.getFullName() + savedUser.getFullName(), + UnitSystem.METRIC ); } From 9c0a9900e88f0ee0f1d2d4ca1b73f13fb96e95a9 Mon Sep 17 00:00:00 2001 From: podlLev Date: Sun, 19 Jul 2026 14:09:36 +0300 Subject: [PATCH 22/62] test: update fixtures for new SecUser/User units field --- .../java/com/weatherviewer/security/SecUser.java | 5 ++++- .../controller/SearchControllerTest.java | 4 +++- .../integration/LocationIntegrationTest.java | 4 +++- .../integration/SearchIntegrationTest.java | 7 +++++-- .../ratelimit/RateLimitingFilterTest.java | 2 +- .../rest/LocationControllerTest.java | 16 ++++++---------- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/weatherviewer/security/SecUser.java b/src/main/java/com/weatherviewer/security/SecUser.java index 8f723b6..54854f1 100644 --- a/src/main/java/com/weatherviewer/security/SecUser.java +++ b/src/main/java/com/weatherviewer/security/SecUser.java @@ -1,6 +1,7 @@ package com.weatherviewer.security; import com.weatherviewer.model.User; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -32,6 +33,7 @@ public class SecUser implements UserDetails { private final Set authorities; private final Boolean isActive; private final String fullName; + private final UnitSystem units; @Override public Collection getAuthorities() { @@ -81,7 +83,8 @@ public static SecUser fromUser(User user) { user.getPassword(), user.getRole().getAuthority(), user.getStatus() == UserStatus.ACTIVE, - user.getFullName() + user.getFullName(), + user.getUnits() ); } diff --git a/src/test/java/com/weatherviewer/controller/SearchControllerTest.java b/src/test/java/com/weatherviewer/controller/SearchControllerTest.java index 44c9c7d..2b030e6 100644 --- a/src/test/java/com/weatherviewer/controller/SearchControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/SearchControllerTest.java @@ -2,6 +2,7 @@ import com.weatherviewer.dto.AddLocationDto; import com.weatherviewer.dto.GeoLocationDto; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.repository.LocationRepository; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; @@ -61,7 +62,8 @@ private SecUser secUser() { "hashed", Set.of(), true, - "John Doe" + "John Doe", + UnitSystem.METRIC ); } diff --git a/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java b/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java index 8cdabca..3311e1d 100644 --- a/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java @@ -3,6 +3,7 @@ import com.weatherviewer.model.Location; import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.LocationRepository; import com.weatherviewer.repository.UserRepository; @@ -61,7 +62,8 @@ void setUp() { savedUser.getPassword(), Set.of(), true, - savedUser.getFullName() + savedUser.getFullName(), + UnitSystem.METRIC ); } diff --git a/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java b/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java index 1df886a..8cad246 100644 --- a/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java @@ -4,6 +4,7 @@ import com.weatherviewer.model.Location; import com.weatherviewer.model.User; import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.LocationRepository; import com.weatherviewer.repository.UserRepository; @@ -68,7 +69,8 @@ void setUp() { savedUser.getPassword(), Set.of(), true, - savedUser.getFullName() + savedUser.getFullName(), + UnitSystem.METRIC ); } @@ -178,7 +180,8 @@ void addLocation_sameCoordinatesDifferentUser_bothSaved() throws Exception { .setRole(Role.USER)); SecUser otherSecUser = new SecUser( otherUser.getId(), otherUser.getEmail(), otherUser.getPassword(), - Set.of(), true, otherUser.getFullName() + Set.of(), true, otherUser.getFullName(), + UnitSystem.METRIC ); mockMvc.perform(post("/search/add") diff --git a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java index aad2e03..a0253bb 100644 --- a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java +++ b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java @@ -55,7 +55,7 @@ private RateLimitProperties.Rule rule(String prefix, int limit) { } private void authenticateAs(UUID userId) { - SecUser secUser = new SecUser(userId, "john@example.com", "hashed", java.util.Set.of(), true, "John Doe"); + SecUser secUser = new SecUser(userId, "john@example.com", "hashed", java.util.Set.of(), true, "John Doe", com.weatherviewer.model.enums.UnitSystem.METRIC); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(secUser, null, secUser.getAuthorities())); } diff --git a/src/test/java/com/weatherviewer/rest/LocationControllerTest.java b/src/test/java/com/weatherviewer/rest/LocationControllerTest.java index 11c88ba..6792e51 100644 --- a/src/test/java/com/weatherviewer/rest/LocationControllerTest.java +++ b/src/test/java/com/weatherviewer/rest/LocationControllerTest.java @@ -4,6 +4,7 @@ import com.weatherviewer.dto.AddLocationDto; import com.weatherviewer.dto.LocationDto; import com.weatherviewer.exception.notfound.LocationNotFoundException; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import org.junit.jupiter.api.BeforeEach; @@ -22,17 +23,11 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -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; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(LocationController.class) @WithMockUser @@ -59,7 +54,8 @@ void setUp() { "hashed", Set.of(), true, - "John Doe" + "John Doe", + UnitSystem.METRIC ); } From 80f38f41e05124bbe504276fece38c21db754225 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 20 Jul 2026 09:54:23 +0300 Subject: [PATCH 23/62] ci: add GitHub Actions workflow for build and test --- .github/workflows/ci.yml | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98a9695 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main", "dev" ] + +permissions: + contents: read + +jobs: + + # --------------------------------------------------------------------- + # 1. Build + test on every push and every PR + # --------------------------------------------------------------------- + test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Run tests + # Tests run against the in-memory H2 DB and spring.cache.type=simple, + # so no Postgres/Redis service containers are needed here. + # WEATHER_API_KEY just needs to be non-empty; tests mock the HTTP client. + env: + WEATHER_API_KEY: dummy-ci-key + run: ./mvnw -B test + + - name: Publish test report + if: always() + uses: dorny/test-reporter@v1 + with: + name: Maven Tests + path: target/surefire-reports/*.xml + reporter: java-junit + + - name: Upload JaCoCo coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-report + path: target/site/jacoco/ + + - name: Build jar + env: + WEATHER_API_KEY: dummy-ci-key + run: ./mvnw -B package -DskipTests + + - name: Upload jar artifact + uses: actions/upload-artifact@v4 + with: + name: app-jar + path: target/*.jar \ No newline at end of file From 7569906d576a2880bbbb3def4e439db2cb3febc5 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 20 Jul 2026 09:54:50 +0300 Subject: [PATCH 24/62] ci: add Docker Hub build and push job --- .github/workflows/ci.yml | 51 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98a9695..e9fd80e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,4 +60,53 @@ jobs: uses: actions/upload-artifact@v4 with: name: app-jar - path: target/*.jar \ No newline at end of file + path: target/*.jar + + # --------------------------------------------------------------------- + # 2. Build + push a versioned image to Docker Hub + # - runs ONLY on: pushes to main + # - every push to main -> : :latest + # - pushes to dev, and all PRs -> this job does not run at all + # (dev/PRs are still covered by the `test` job above) + # --------------------------------------------------------------------- + docker: + name: Build & Push Docker Image + needs: test + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read version from pom.xml + id: pom + run: | + VERSION=$(./mvnw -q -B help:evaluate -Dexpression=project.version -DforceStdout) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/weatherviewer + tags: | + type=raw,value=${{ steps.pom.outputs.version }} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file From 5b8adec9bf8f7565a00a8deef792750edee155ed Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 20 Jul 2026 10:13:41 +0300 Subject: [PATCH 25/62] fix: make mvnw executable --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9fd80e..2ef7a9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: java-version: '17' cache: maven + - name: Make mvnw executable + run: chmod +x ./mvnw + - name: Run tests # Tests run against the in-memory H2 DB and spring.cache.type=simple, # so no Postgres/Redis service containers are needed here. From 1d1a5f372cf99de83bba8449d69f78d3cf140de3 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 20 Jul 2026 10:35:52 +0300 Subject: [PATCH 26/62] ci: bump actions to Node 24-compatible versions --- .github/workflows/ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ef7a9a..270625d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [ "main" ] pull_request: - branches: [ "main", "dev" ] + branches: [ "main", "dev" ] permissions: contents: read @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: '17' @@ -41,7 +41,7 @@ jobs: - name: Publish test report if: always() - uses: dorny/test-reporter@v1 + uses: dorny/test-reporter@v3 with: name: Maven Tests path: target/surefire-reports/*.xml @@ -49,7 +49,7 @@ jobs: - name: Upload JaCoCo coverage report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: jacoco-report path: target/site/jacoco/ @@ -60,7 +60,7 @@ jobs: run: ./mvnw -B package -DskipTests - name: Upload jar artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: app-jar path: target/*.jar @@ -79,11 +79,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Read version from pom.xml id: pom run: | + chmod +x ./mvnw VERSION=$(./mvnw -q -B help:evaluate -Dexpression=project.version -DforceStdout) echo "version=$VERSION" >> "$GITHUB_OUTPUT" From 4fe641e01ab6aa9e3c635b18afb184275eccb533 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 20 Jul 2026 10:43:00 +0300 Subject: [PATCH 27/62] fix: explicitly ignore auto-generated/default fields in mappers --- .../java/com/weatherviewer/mapper/LocationMapper.java | 9 ++++++++- src/main/java/com/weatherviewer/mapper/UserMapper.java | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/weatherviewer/mapper/LocationMapper.java b/src/main/java/com/weatherviewer/mapper/LocationMapper.java index 4a66169..68e2d65 100644 --- a/src/main/java/com/weatherviewer/mapper/LocationMapper.java +++ b/src/main/java/com/weatherviewer/mapper/LocationMapper.java @@ -35,9 +35,16 @@ public abstract class LocationMapper { */ @Mapping(target = "user", expression = "java(userRepository.getReferenceById(addLocationDto.getUserId()))") @Mapping(target = "favorite", expression = "java(addLocationDto.getFavorite() != null ? addLocationDto.getFavorite() : false)") + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) public abstract Location fromDto(AddLocationDto addLocationDto); - /** Builds a {@link Location} from its full DTO representation (e.g. for updates). */ + /** + * Builds a {@link Location} from its full DTO representation (e.g. for updates). + * The owning {@code user} is intentionally left unset here — callers that need + * a persisted, owned entity set it separately after this conversion. + */ + @Mapping(target = "user", ignore = true) public abstract Location fromDto(LocationDto locationDto); /** diff --git a/src/main/java/com/weatherviewer/mapper/UserMapper.java b/src/main/java/com/weatherviewer/mapper/UserMapper.java index d4a3e23..caf54e5 100644 --- a/src/main/java/com/weatherviewer/mapper/UserMapper.java +++ b/src/main/java/com/weatherviewer/mapper/UserMapper.java @@ -22,6 +22,10 @@ public interface UserMapper { */ @Mapping(target = "status", expression = "java(com.weatherviewer.model.enums.UserStatus.ACTIVE)") @Mapping(target = "role", expression = "java(com.weatherviewer.model.enums.Role.USER)") + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "units", ignore = true) + @Mapping(target = "locations", ignore = true) User fromRecord(CreateUserDto createUserDto); /** From 65205ea3232b9d75f65fbfa9552cc33a631b50b1 Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:55:35 +0300 Subject: [PATCH 28/62] feat(ui): add dark mode toggle and theme styles across pages --- src/main/resources/static/css/theme.css | 62 +++++++++++++++++++ src/main/resources/static/js/theme-init.js | 17 +++++ src/main/resources/static/js/theme-toggle.js | 41 ++++++++++++ src/main/resources/templates/forecast.html | 1 + .../resources/templates/fragments/common.html | 9 ++- .../resources/templates/header-component.html | 7 ++- src/main/resources/templates/home.html | 11 ++-- src/main/resources/templates/profile.html | 1 + src/main/resources/templates/search.html | 1 + 9 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/static/css/theme.css create mode 100644 src/main/resources/static/js/theme-init.js create mode 100644 src/main/resources/static/js/theme-toggle.js diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css new file mode 100644 index 0000000..dfa4004 --- /dev/null +++ b/src/main/resources/static/css/theme.css @@ -0,0 +1,62 @@ +/* + * Dark-mode support, layered on top of Bootstrap 5.3's built-in + * `data-bs-theme` color modes. Bootstrap's own components (cards, forms, + * navbars, toasts, etc.) already re-theme themselves off `data-bs-theme`; + * the overrides below only cover this app's custom, non-Bootstrap classes + * (the auth pages' `.bg-image`/`.form`, and the toast font import) that + * don't automatically follow the color mode. + */ + +body { + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; +} + +/* Auth pages (sign-in / sign-up / forgot / reset password) */ +[data-bs-theme="dark"] .bg-image { + filter: brightness(0.45) saturate(0.9); +} + +[data-bs-theme="dark"] .form { + background-color: rgba(33, 37, 41, 0.92); + color: var(--bs-body-color); +} + +[data-bs-theme="dark"] .form .text-muted { + color: #adb5bd !important; +} + +/* Toasts (fragments/alerts.html) render on a light card regardless of page theme by design. + Text color alone isn't enough here — Bootstrap's dark-mode .toast background is dark by + default, so without forcing the background too, this dark (#222) text sits on a dark card. */ +[data-bs-theme="dark"] .toast { + --bs-toast-bg: #fff; +} + +[data-bs-theme="dark"] .toast-body, +[data-bs-theme="dark"] .toast-header { + color: #222; + background-color: #fff; +} + +/* Weather cards (home.html) — fixed min-height so cards line up visually + even when description length differs (e.g. "Overcast clouds" vs + "Moderate rain"), and regardless of which flex line they wrap onto. */ +.weather-card { + width: 38rem; + min-height: 650px; +} + +/* Theme toggle button in the header */ +.theme-toggle-btn { + border: none; + background: transparent; + font-size: 1.25rem; + line-height: 1; + color: var(--bs-body-color); + padding: 0.375rem 0.5rem; + border-radius: 0.5rem; +} + +.theme-toggle-btn:hover { + background-color: var(--bs-secondary-bg); +} \ No newline at end of file diff --git a/src/main/resources/static/js/theme-init.js b/src/main/resources/static/js/theme-init.js new file mode 100644 index 0000000..6fc59aa --- /dev/null +++ b/src/main/resources/static/js/theme-init.js @@ -0,0 +1,17 @@ +/* + * Runs synchronously in , before the body is parsed, so the page + * never flashes the wrong color mode. Precedence: an explicit choice saved + * by theme-toggle.js, then the OS-level preference, then light. + */ +(function () { + try { + var saved = window.localStorage.getItem('theme'); + var theme = saved === 'dark' || saved === 'light' + ? saved + : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + + document.documentElement.setAttribute('data-bs-theme', theme); + } catch (e) { + document.documentElement.setAttribute('data-bs-theme', 'light'); + } +})(); \ No newline at end of file diff --git a/src/main/resources/static/js/theme-toggle.js b/src/main/resources/static/js/theme-toggle.js new file mode 100644 index 0000000..1edd33b --- /dev/null +++ b/src/main/resources/static/js/theme-toggle.js @@ -0,0 +1,41 @@ +(function () { + function syncButtonState() { + var button = document.getElementById('theme-toggle-btn'); + if (!button) return; + + var currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light'; + var icon = button.querySelector('i'); + + if (currentTheme === 'dark') { + if (icon) icon.className = 'bi bi-sun-fill'; + button.setAttribute('aria-label', 'Switch to light mode'); + button.setAttribute('title', 'Switch to light mode'); + } else { + if (icon) icon.className = 'bi bi-moon-stars-fill'; + button.setAttribute('aria-label', 'Switch to dark mode'); + button.setAttribute('title', 'Switch to dark mode'); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', syncButtonState); + } else { + syncButtonState(); + } + + document.addEventListener('click', function (event) { + var button = event.target.closest('#theme-toggle-btn'); + if (!button) return; + + var currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light'; + var nextTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + document.documentElement.setAttribute('data-bs-theme', nextTheme); + + try { + window.localStorage.setItem('theme', nextTheme); + } catch (e) { + } + syncButtonState(); + }); +})(); \ No newline at end of file diff --git a/src/main/resources/templates/forecast.html b/src/main/resources/templates/forecast.html index 4617e0f..7b291a4 100644 --- a/src/main/resources/templates/forecast.html +++ b/src/main/resources/templates/forecast.html @@ -57,6 +57,7 @@

      + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/common.html b/src/main/resources/templates/fragments/common.html index 4bd217a..0933bd9 100644 --- a/src/main/resources/templates/fragments/common.html +++ b/src/main/resources/templates/fragments/common.html @@ -8,14 +8,17 @@ - + + + + - +
      diff --git a/src/main/resources/templates/header-component.html b/src/main/resources/templates/header-component.html index 80b001f..b3dd6cf 100644 --- a/src/main/resources/templates/header-component.html +++ b/src/main/resources/templates/header-component.html @@ -10,12 +10,17 @@

      Weather Viewer

      +
      + +
      - + Username diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index 00c6516..bb19669 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -30,8 +30,8 @@

      Current Weather

      -
      -
      +
      +
      @@ -103,7 +103,7 @@

      +

      @@ -203,8 +203,9 @@

      - + + diff --git a/src/main/resources/templates/profile.html b/src/main/resources/templates/profile.html index 8273d40..23edacd 100644 --- a/src/main/resources/templates/profile.html +++ b/src/main/resources/templates/profile.html @@ -66,6 +66,7 @@

      Account Settings

      + diff --git a/src/main/resources/templates/search.html b/src/main/resources/templates/search.html index 2db9756..0c1b5b1 100644 --- a/src/main/resources/templates/search.html +++ b/src/main/resources/templates/search.html @@ -45,6 +45,7 @@
      Name
      + \ No newline at end of file From 9a47a99cd96e7ea8e64bc5be97aa3e9d86aaf2b8 Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:56:09 +0300 Subject: [PATCH 29/62] chore(config): add liquibase migration and properties for verification tokens --- src/main/resources/application.properties | 24 ++++++++++ src/main/resources/liquibase/changelog.xml | 1 + .../update/05-add-verification-tokens.xml | 46 +++++++++++++++++++ src/test/resources/application.properties | 4 ++ 4 files changed, 75 insertions(+) create mode 100644 src/main/resources/liquibase/update/05-add-verification-tokens.xml diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 718fa96..f273614 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,6 +6,13 @@ spring.datasource.username=${SPRING_DATASOURCE_USERNAME} spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.hikari.keepalive-time=120000 +spring.datasource.hikari.max-lifetime=600000 +spring.datasource.hikari.idle-timeout=300000 +spring.datasource.hikari.minimum-idle=2 +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.validation-timeout=5000 + spring.jpa.open-in-view=false spring.mvc.hiddenmethod.filter.enabled=true spring.jpa.properties.hibernate.jdbc.time_zone=UTC @@ -50,6 +57,23 @@ spring.data.redis.port=${SPRING_DATA_REDIS_PORT:6379} spring.cache.type=${SPRING_CACHE_TYPE:simple} spring.cache.redis.time-to-live=3600000 +# --- Mail (verification / password reset) --- +spring.mail.host=${MAIL_HOST:localhost} +spring.mail.port=${MAIL_PORT:1025} +spring.mail.username=${MAIL_USERNAME:} +spring.mail.password=${MAIL_PASSWORD:} +spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false} +spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false} +spring.mail.properties.mail.smtp.connectiontimeout=5000 +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.writetimeout=5000 +app.mail.from=${MAIL_FROM:no-reply@weatherviewer.local} + +# --- Links embedded in emailed verification / reset tokens --- +app.base-url=${APP_BASE_URL:http://localhost:8080} +app.verification.token-ttl-hours=24 +app.password-reset.token-ttl-hours=1 + # --- Actuator / health --- management.endpoints.web.exposure.include=health management.endpoint.health.show-details=when-authorized diff --git a/src/main/resources/liquibase/changelog.xml b/src/main/resources/liquibase/changelog.xml index 6fbc898..0a4206f 100644 --- a/src/main/resources/liquibase/changelog.xml +++ b/src/main/resources/liquibase/changelog.xml @@ -7,5 +7,6 @@ + diff --git a/src/main/resources/liquibase/update/05-add-verification-tokens.xml b/src/main/resources/liquibase/update/05-add-verification-tokens.xml new file mode 100644 index 0000000..1d2a632 --- /dev/null +++ b/src/main/resources/liquibase/update/05-add-verification-tokens.xml @@ -0,0 +1,46 @@ + + + + + + CREATE TYPE token_type AS ENUM ('EMAIL_VERIFICATION','PASSWORD_RESET'); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index bc0b038..a674d26 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -18,3 +18,7 @@ weather.api.key=${WEATHER_API_KEY} spring.data.redis.host=localhost spring.data.redis.port=6379 spring.cache.type=simple + +# --- Mail --- +spring.mail.host=localhost +spring.mail.port=1025 From 0b3444c7579d4fd8cdd370dfcb64fdb347feedce Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:57:37 +0300 Subject: [PATCH 30/62] feat(auth): create VerificationToken entity, repository, DTOs, and exceptions --- .../weatherviewer/dto/ForgotPasswordDto.java | 23 +++ .../weatherviewer/dto/ResetPasswordDto.java | 37 +++++ .../exception/InvalidTokenException.java | 14 ++ .../model/VerificationToken.java | 57 ++++++++ .../weatherviewer/model/enums/TokenType.java | 18 +++ .../VerificationTokenRepository.java | 23 +++ .../dto/ForgotPasswordDtoTest.java | 59 ++++++++ .../dto/ResetPasswordDtoTest.java | 128 +++++++++++++++++ .../exception/InvalidTokenExceptionTest.java | 29 ++++ .../model/VerificationTokenTest.java | 57 ++++++++ .../model/enums/TokenTypeTest.java | 31 +++++ .../VerificationTokenRepositoryTest.java | 131 ++++++++++++++++++ 12 files changed, 607 insertions(+) create mode 100644 src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java create mode 100644 src/main/java/com/weatherviewer/dto/ResetPasswordDto.java create mode 100644 src/main/java/com/weatherviewer/exception/InvalidTokenException.java create mode 100644 src/main/java/com/weatherviewer/model/VerificationToken.java create mode 100644 src/main/java/com/weatherviewer/model/enums/TokenType.java create mode 100644 src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java create mode 100644 src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java create mode 100644 src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java create mode 100644 src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java create mode 100644 src/test/java/com/weatherviewer/model/VerificationTokenTest.java create mode 100644 src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java create mode 100644 src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java diff --git a/src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java b/src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java new file mode 100644 index 0000000..d127d7a --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java @@ -0,0 +1,23 @@ +package com.weatherviewer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +@Getter +@Setter +@Accessors(chain = true) +@Schema(description = "Payload for requesting a password reset email") +public class ForgotPasswordDto { + + @Schema(description = "Email address of the account to reset", example = "jane.doe@example.com", maxLength = 150) + @Email(message = "Invalid email format") + @NotBlank(message = "Email cannot be blank") + @Size(max = 150, message = "Email cannot exceed 150 characters") + private String email; + +} diff --git a/src/main/java/com/weatherviewer/dto/ResetPasswordDto.java b/src/main/java/com/weatherviewer/dto/ResetPasswordDto.java new file mode 100644 index 0000000..d34be78 --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ResetPasswordDto.java @@ -0,0 +1,37 @@ +package com.weatherviewer.dto; + +import com.weatherviewer.validation.annotation.Password; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.Accessors; + +@Getter +@Setter +@ToString +@Accessors(chain = true) +@Schema(description = "Payload for redeeming a password-reset link") +public class ResetPasswordDto { + + @Schema(description = "Password reset token from the emailed link", accessMode = Schema.AccessMode.WRITE_ONLY) + @NotBlank(message = "Reset link is invalid") + @ToString.Exclude + private String token; + + @Schema(description = "New password (up to 72 characters, bcrypt-hashed server-side)", maxLength = 72) + @Password + @NotBlank(message = "Password cannot be blank") + @Size(max = 72, message = "Password cannot exceed 72 characters") + @ToString.Exclude + private String password; + + @Schema(description = "Must match `password`", maxLength = 72) + @NotBlank(message = "Repeat password cannot be blank") + @Size(max = 72, message = "Password cannot exceed 72 characters") + @ToString.Exclude + private String repeatPassword; + +} diff --git a/src/main/java/com/weatherviewer/exception/InvalidTokenException.java b/src/main/java/com/weatherviewer/exception/InvalidTokenException.java new file mode 100644 index 0000000..f3d6b81 --- /dev/null +++ b/src/main/java/com/weatherviewer/exception/InvalidTokenException.java @@ -0,0 +1,14 @@ +package com.weatherviewer.exception; + +/** + * Thrown when an email-verification or password-reset token is missing, + * of the wrong {@link com.weatherviewer.model.enums.TokenType}, already + * used, or expired. + */ +public class InvalidTokenException extends RuntimeException { + + public InvalidTokenException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/weatherviewer/model/VerificationToken.java b/src/main/java/com/weatherviewer/model/VerificationToken.java new file mode 100644 index 0000000..b3d0528 --- /dev/null +++ b/src/main/java/com/weatherviewer/model/VerificationToken.java @@ -0,0 +1,57 @@ +package com.weatherviewer.model; + +import com.weatherviewer.model.enums.TokenType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; + +/** + * A single-use, expiring token used for either email verification or + * password reset (see {@link TokenType}). + *

      + * Tokens are opaque random strings (never the entity's own {@code id}) so + * they can be safely embedded in an emailed URL. Each token is valid until + * either {@link #expiresAt} passes or it is consumed once ({@link #used}). + */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Accessors(chain = true) +@Table(name = "verification_tokens") +public class VerificationToken extends BaseEntity { + + /** Opaque, unique, unguessable token value embedded in the emailed link. */ + @Column(unique = true, nullable = false) + private String token; + + /** The account this token grants an action for. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** What this token authorizes: confirming an email or resetting a password. */ + @Enumerated(EnumType.STRING) + @Column(name = "type", columnDefinition = "token_type", nullable = false) + @JdbcType(PostgreSQLEnumJdbcType.class) + private TokenType type; + + /** Moment after which the token can no longer be redeemed. */ + @Column(nullable = false) + private LocalDateTime expiresAt; + + /** Whether the token has already been redeemed; consumed tokens can never be reused. */ + private boolean used; + + /** @return {@code true} if the token is still within its validity window and has not been redeemed yet. */ + public boolean isValid() { + return !used && expiresAt.isAfter(LocalDateTime.now()); + } + +} diff --git a/src/main/java/com/weatherviewer/model/enums/TokenType.java b/src/main/java/com/weatherviewer/model/enums/TokenType.java new file mode 100644 index 0000000..3a05bb4 --- /dev/null +++ b/src/main/java/com/weatherviewer/model/enums/TokenType.java @@ -0,0 +1,18 @@ +package com.weatherviewer.model.enums; + +/** + * The purpose of a {@link com.weatherviewer.model.VerificationToken}. + *

      + * Both email verification and password reset use the same underlying + * token table; this enum keeps the two purposes from being interchangeable + * (a leaked verification link can never be used to reset a password, and + * vice versa). + */ +public enum TokenType { + + /** Confirms a newly registered account's email address. */ + EMAIL_VERIFICATION, + /** Authorizes a one-time password reset. */ + PASSWORD_RESET + +} diff --git a/src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java b/src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java new file mode 100644 index 0000000..4868290 --- /dev/null +++ b/src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java @@ -0,0 +1,23 @@ +package com.weatherviewer.repository; + +import com.weatherviewer.model.VerificationToken; +import com.weatherviewer.model.enums.TokenType; +import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; +import java.util.UUID; + +public interface VerificationTokenRepository extends JpaRepository { + + Optional findByTokenAndType(String token, TokenType type); + + /** Invalidates any earlier, still-unused tokens of the given type for a user before a fresh one is issued. */ + @Modifying + @Query("update VerificationToken t set t.used = true " + + "where t.user.id = :userId and t.type = :type and t.used = false") + void invalidateActiveTokens(@Param("userId") UUID userId, @Param("type") TokenType type); + +} diff --git a/src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java b/src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java new file mode 100644 index 0000000..6d8ab5a --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java @@ -0,0 +1,59 @@ +package com.weatherviewer.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertFieldHasViolation; +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertNoViolations; + +class ForgotPasswordDtoTest { + + private Validator validator; + + @BeforeEach + void setUp() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + private ForgotPasswordDto validDto() { + return new ForgotPasswordDto().setEmail("john@example.com"); + } + + @Test + void valid_dto_hasNoViolations() { + assertNoViolations(validator, validDto()); + } + + @Test + void email_null_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail(null); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_blank_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail(""); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_whitespaceOnly_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail(" "); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_invalidFormat_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail("not-an-email"); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_exceedsMaxLength_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail("a".repeat(145) + "@x.com"); + assertFieldHasViolation(validator, dto, "email"); + } + +} diff --git a/src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java b/src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java new file mode 100644 index 0000000..4087e86 --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java @@ -0,0 +1,128 @@ +package com.weatherviewer.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertFieldHasViolation; +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertNoViolations; +import static org.assertj.core.api.Assertions.assertThat; + +class ResetPasswordDtoTest { + + private Validator validator; + + @BeforeEach + void setUp() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + private ResetPasswordDto validDto() { + return new ResetPasswordDto() + .setToken("raw-token") + .setPassword("Secure1@") + .setRepeatPassword("Secure1@"); + } + + @Test + void valid_dto_hasNoViolations() { + assertNoViolations(validator, validDto()); + } + + @Test + void token_null_failsValidation() { + ResetPasswordDto dto = validDto().setToken(null); + assertFieldHasViolation(validator, dto, "token"); + } + + @Test + void token_blank_failsValidation() { + ResetPasswordDto dto = validDto().setToken(""); + assertFieldHasViolation(validator, dto, "token"); + } + + @Test + void password_null_failsValidation() { + ResetPasswordDto dto = validDto().setPassword(null); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_blank_failsValidation() { + ResetPasswordDto dto = validDto().setPassword(""); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noUpperCase_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("secure1@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noLowerCase_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("SECURE1@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noDigit_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("Secure@@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noSpecialChar_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("Secure11"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_tooShort_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("Se1@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_exceedsMaxLength_failsValidation() { + // 71 letters (upper/lower alternating) + digit + symbol = 73 chars, over the 72 max + String tooLong = "Aa".repeat(35) + "A1@"; + ResetPasswordDto dto = validDto().setPassword(tooLong); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void repeatPassword_null_failsValidation() { + ResetPasswordDto dto = validDto().setRepeatPassword(null); + assertFieldHasViolation(validator, dto, "repeatPassword"); + } + + @Test + void repeatPassword_blank_failsValidation() { + ResetPasswordDto dto = validDto().setRepeatPassword(""); + assertFieldHasViolation(validator, dto, "repeatPassword"); + } + + @Test + void repeatPassword_exceedsMaxLength_failsValidation() { + ResetPasswordDto dto = validDto().setRepeatPassword("A".repeat(73)); + assertFieldHasViolation(validator, dto, "repeatPassword"); + } + + @Test + void mismatchedPasswords_notEnforcedByBeanValidation_onlyByControllerLogic() { + ResetPasswordDto dto = validDto().setRepeatPassword("Different1@"); + assertNoViolations(validator, dto); + } + + @Test + void toString_excludesTokenAndPasswordFields() { + String toString = validDto().toString(); + + assertThat(toString) + .doesNotContain("raw-token") + .doesNotContain("Secure1@"); + } + +} diff --git a/src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java b/src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java new file mode 100644 index 0000000..62ed071 --- /dev/null +++ b/src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java @@ -0,0 +1,29 @@ +package com.weatherviewer.exception; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InvalidTokenExceptionTest { + + @Test + void constructor_setsMessage() { + InvalidTokenException ex = new InvalidTokenException("This link is invalid."); + assertThat(ex.getMessage()).isEqualTo("This link is invalid."); + } + + @Test + void isInstanceOf_RuntimeException() { + InvalidTokenException ex = new InvalidTokenException("This link is invalid."); + assertThat(ex).isInstanceOf(RuntimeException.class); + } + + @Test + void thrown_caughtAsRuntimeException() { + assertThatThrownBy(() -> { throw new InvalidTokenException("This link has expired or was already used."); }) + .isInstanceOf(RuntimeException.class) + .hasMessage("This link has expired or was already used."); + } + +} diff --git a/src/test/java/com/weatherviewer/model/VerificationTokenTest.java b/src/test/java/com/weatherviewer/model/VerificationTokenTest.java new file mode 100644 index 0000000..e9388fa --- /dev/null +++ b/src/test/java/com/weatherviewer/model/VerificationTokenTest.java @@ -0,0 +1,57 @@ +package com.weatherviewer.model; + +import com.weatherviewer.model.enums.TokenType; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VerificationTokenTest { + + private VerificationToken token(boolean used, LocalDateTime expiresAt) { + return new VerificationToken() + .setToken("raw-token") + .setType(TokenType.EMAIL_VERIFICATION) + .setUsed(used) + .setExpiresAt(expiresAt); + } + + @Test + void isValid_notUsedAndNotExpired_returnsTrue() { + VerificationToken token = token(false, LocalDateTime.now().plusHours(1)); + + assertTrue(token.isValid()); + } + + @Test + void isValid_usedButNotExpired_returnsFalse() { + VerificationToken token = token(true, LocalDateTime.now().plusHours(1)); + + assertFalse(token.isValid()); + } + + @Test + void isValid_notUsedButExpired_returnsFalse() { + VerificationToken token = token(false, LocalDateTime.now().minusHours(1)); + + assertFalse(token.isValid()); + } + + @Test + void isValid_usedAndExpired_returnsFalse() { + VerificationToken token = token(true, LocalDateTime.now().minusHours(1)); + + assertFalse(token.isValid()); + } + + @Test + void isValid_expiresExactlyNow_returnsFalse() { + LocalDateTime now = LocalDateTime.now(); + VerificationToken token = token(false, now); + + assertFalse(token.isValid()); + } + +} diff --git a/src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java b/src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java new file mode 100644 index 0000000..c16f651 --- /dev/null +++ b/src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java @@ -0,0 +1,31 @@ +package com.weatherviewer.model.enums; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TokenTypeTest { + + @Test + void values_containsExactlyEmailVerificationAndPasswordReset() { + assertThat(TokenType.values()) + .containsExactly(TokenType.EMAIL_VERIFICATION, TokenType.PASSWORD_RESET); + } + + @Test + void valueOf_emailVerification_returnsMatchingConstant() { + assertThat(TokenType.valueOf("EMAIL_VERIFICATION")).isEqualTo(TokenType.EMAIL_VERIFICATION); + } + + @Test + void valueOf_passwordReset_returnsMatchingConstant() { + assertThat(TokenType.valueOf("PASSWORD_RESET")).isEqualTo(TokenType.PASSWORD_RESET); + } + + @Test + void valueOf_unknownConstant_throwsIllegalArgumentException() { + assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> TokenType.valueOf("NOT_A_TYPE"))).hasMessageContaining("NOT_A_TYPE"); + } + +} diff --git a/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java b/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java new file mode 100644 index 0000000..08de6de --- /dev/null +++ b/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java @@ -0,0 +1,131 @@ +package com.weatherviewer.repository; + +import com.weatherviewer.model.User; +import com.weatherviewer.model.VerificationToken; +import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.TokenType; +import com.weatherviewer.model.enums.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class VerificationTokenRepositoryTest { + + @Autowired + TestEntityManager entityManager; + + @Autowired + VerificationTokenRepository tokenRepository; + + private User user; + private User otherUser; + + @BeforeEach + void setUp() { + user = entityManager.persistAndFlush(new User() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setPassword("password") + .setStatus(UserStatus.PENDING) + .setRole(Role.USER)); + + otherUser = entityManager.persistAndFlush(new User() + .setEmail("jane@example.com") + .setFirstName("Jane") + .setLastName("Doe") + .setPassword("password") + .setStatus(UserStatus.PENDING) + .setRole(Role.USER)); + } + + private VerificationToken token(String value, TokenType type, User owner, boolean used, LocalDateTime expiresAt) { + return new VerificationToken() + .setToken(value) + .setType(type) + .setUser(owner) + .setUsed(used) + .setExpiresAt(expiresAt); + } + + @Test + void findByTokenAndType_returnsTokenWhenExists() { + VerificationToken saved = entityManager.persistAndFlush( + token("abc123", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + + Optional result = tokenRepository.findByTokenAndType("abc123", TokenType.EMAIL_VERIFICATION); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(saved); + } + + @Test + void findByTokenAndType_wrongType_returnsEmpty() { + entityManager.persistAndFlush( + token("abc123", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + + Optional result = tokenRepository.findByTokenAndType("abc123", TokenType.PASSWORD_RESET); + + assertThat(result).isEmpty(); + } + + @Test + void findByTokenAndType_unknownToken_returnsEmpty() { + Optional result = tokenRepository.findByTokenAndType("does-not-exist", TokenType.EMAIL_VERIFICATION); + + assertThat(result).isEmpty(); + } + + @Test + void invalidateActiveTokens_marksOnlyMatchingUserAndTypeAsUsed() { + VerificationToken target = entityManager.persistAndFlush( + token("target-token", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + entityManager.persistAndFlush( + token("other-type-token", TokenType.PASSWORD_RESET, user, false, LocalDateTime.now().plusHours(24))); + entityManager.persistAndFlush( + token("other-user-token", TokenType.EMAIL_VERIFICATION, otherUser, false, LocalDateTime.now().plusHours(24))); + + tokenRepository.invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION); + entityManager.clear(); + + VerificationToken reloaded = tokenRepository.findById(target.getId()).orElseThrow(); + assertThat(reloaded.isUsed()).isTrue(); + + VerificationToken otherType = tokenRepository.findByTokenAndType("other-type-token", TokenType.PASSWORD_RESET).orElseThrow(); + assertThat(otherType.isUsed()).isFalse(); + + VerificationToken otherUserToken = tokenRepository.findByTokenAndType("other-user-token", TokenType.EMAIL_VERIFICATION).orElseThrow(); + assertThat(otherUserToken.isUsed()).isFalse(); + } + + @Test + void invalidateActiveTokens_alreadyUsedToken_isLeftUnchanged() { + VerificationToken used = entityManager.persistAndFlush( + token("already-used", TokenType.EMAIL_VERIFICATION, user, true, LocalDateTime.now().plusHours(24))); + + tokenRepository.invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION); + entityManager.clear(); + + VerificationToken reloaded = tokenRepository.findById(used.getId()).orElseThrow(); + assertThat(reloaded.isUsed()).isTrue(); + } + + @Test + void save_duplicateTokenValue_violatesUniqueConstraint() { + entityManager.persistAndFlush( + token("duplicate", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + + org.junit.jupiter.api.Assertions.assertThrows(Exception.class, () -> + entityManager.persistAndFlush( + token("duplicate", TokenType.PASSWORD_RESET, otherUser, false, LocalDateTime.now().plusHours(1)))); + } + +} From 4b834fe8e0770b23a80c8ac4eb39712fb0573223 Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:58:18 +0300 Subject: [PATCH 31/62] feat(auth): implement MailService and VerificationService --- .../com/weatherviewer/mapper/UserMapper.java | 10 +- .../weatherviewer/service/MailService.java | 16 + .../service/VerificationService.java | 37 +++ .../service/impl/MailServiceImpl.java | 72 +++++ .../service/impl/VerificationServiceImpl.java | 146 +++++++++ .../weatherviewer/mapper/UserMapperTest.java | 4 +- .../service/impl/MailServiceImplTest.java | 112 +++++++ .../impl/VerificationServiceImplTest.java | 299 ++++++++++++++++++ 8 files changed, 690 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/weatherviewer/service/MailService.java create mode 100644 src/main/java/com/weatherviewer/service/VerificationService.java create mode 100644 src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java create mode 100644 src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java create mode 100644 src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java create mode 100644 src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java diff --git a/src/main/java/com/weatherviewer/mapper/UserMapper.java b/src/main/java/com/weatherviewer/mapper/UserMapper.java index caf54e5..31bb030 100644 --- a/src/main/java/com/weatherviewer/mapper/UserMapper.java +++ b/src/main/java/com/weatherviewer/mapper/UserMapper.java @@ -16,11 +16,13 @@ public interface UserMapper { /** * Builds a new {@link User} from a registration payload. New accounts - * are always created with {@code status = ACTIVE} and {@code role = USER}; - * the password field is copied as-is and must already be hashed by the - * caller before persisting. + * are always created with {@code status = PENDING} (until the owner + * confirms their email address via the verification link — see + * {@link com.weatherviewer.service.VerificationService}) and + * {@code role = USER}; the password field is copied as-is and must + * already be hashed by the caller before persisting. */ - @Mapping(target = "status", expression = "java(com.weatherviewer.model.enums.UserStatus.ACTIVE)") + @Mapping(target = "status", expression = "java(com.weatherviewer.model.enums.UserStatus.PENDING)") @Mapping(target = "role", expression = "java(com.weatherviewer.model.enums.Role.USER)") @Mapping(target = "id", ignore = true) @Mapping(target = "createdAt", ignore = true) diff --git a/src/main/java/com/weatherviewer/service/MailService.java b/src/main/java/com/weatherviewer/service/MailService.java new file mode 100644 index 0000000..280e0cb --- /dev/null +++ b/src/main/java/com/weatherviewer/service/MailService.java @@ -0,0 +1,16 @@ +package com.weatherviewer.service; + +/** + * Outbound transactional email. Implementations must never let a mail + * provider outage break the calling request (sign-up, password reset) — + * failures are logged and swallowed rather than propagated. + */ +public interface MailService { + + /** Sends a new-account email containing a link to confirm the given address. */ + void sendVerificationEmail(String to, String firstName, String verificationLink); + + /** Sends a link allowing the recipient to set a new password. */ + void sendPasswordResetEmail(String to, String firstName, String resetLink); + +} diff --git a/src/main/java/com/weatherviewer/service/VerificationService.java b/src/main/java/com/weatherviewer/service/VerificationService.java new file mode 100644 index 0000000..9d7d815 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/VerificationService.java @@ -0,0 +1,37 @@ +package com.weatherviewer.service; + +import com.weatherviewer.model.User; + +/** + * Issues and redeems the one-time tokens behind email verification and + * password reset. + */ +public interface VerificationService { + + /** Generates a fresh email-verification token for {@code user} and emails it to them. */ + void sendVerificationEmail(User user); + + /** + * Redeems an email-verification token: activates the owning account and + * consumes the token. + * + * @throws com.weatherviewer.exception.InvalidTokenException if the token is unknown, of the wrong type, expired, or already used + */ + void confirmEmail(String token); + + /** + * If {@code email} belongs to an account, issues a password-reset token + * and emails it. Does nothing (and never reveals whether the address is + * registered) otherwise, to avoid leaking which emails have accounts. + */ + void requestPasswordReset(String email); + + /** + * Redeems a password-reset token, setting the owning account's password + * to {@code newRawPassword} and consuming the token. + * + * @throws com.weatherviewer.exception.InvalidTokenException if the token is unknown, of the wrong type, expired, or already used + */ + void resetPassword(String token, String newRawPassword); + +} diff --git a/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java new file mode 100644 index 0000000..be96b35 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java @@ -0,0 +1,72 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.service.MailService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +/** + * {@link MailService} implementation backed by {@link JavaMailSender}. + *

      + * Emails are plain text on purpose (no external images/styles to fetch, + * nothing for a mail client to block). A misconfigured or unreachable SMTP + * server never bubbles up as a 500 to the user — sign-up and password + * reset both still succeed, they just log a warning that no email went + * out, so the account/token itself is never lost because of a mail outage. + */ +@Service +@Slf4j +public class MailServiceImpl implements MailService { + + private final JavaMailSender mailSender; + private final String fromAddress; + + public MailServiceImpl(JavaMailSender mailSender, + @Value("${app.mail.from:no-reply@weatherviewer.local}") String fromAddress) { + this.mailSender = mailSender; + this.fromAddress = fromAddress; + } + + @Override + public void sendVerificationEmail(String to, String firstName, String verificationLink) { + String subject = "Verify your WeatherViewer account"; + String body = "Hi " + firstName + ",\n\n" + + "Thanks for signing up for WeatherViewer! Please confirm your email address by opening the link below:\n\n" + + verificationLink + "\n\n" + + "This link expires in 24 hours. If you didn't create this account, you can safely ignore this email.\n\n" + + "— The WeatherViewer team"; + + send(to, subject, body); + } + + @Override + public void sendPasswordResetEmail(String to, String firstName, String resetLink) { + String subject = "Reset your WeatherViewer password"; + String body = "Hi " + firstName + ",\n\n" + + "We received a request to reset your WeatherViewer password. Open the link below to choose a new one:\n\n" + + resetLink + "\n\n" + + "This link expires in 1 hour. If you didn't request this, you can safely ignore this email — " + + "your password will not be changed.\n\n" + + "— The WeatherViewer team"; + + send(to, subject, body); + } + + private void send(String to, String subject, String body) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + mailSender.send(message); + log.info("Sent email '{}' to {}", subject, to); + } catch (MailException e) { + log.warn("Failed to send email '{}' to {}: {}", subject, to, e.getMessage()); + } + } + +} diff --git a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java new file mode 100644 index 0000000..fc60ac5 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java @@ -0,0 +1,146 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.model.User; +import com.weatherviewer.model.VerificationToken; +import com.weatherviewer.model.enums.TokenType; +import com.weatherviewer.model.enums.UserStatus; +import com.weatherviewer.repository.UserRepository; +import com.weatherviewer.repository.VerificationTokenRepository; +import com.weatherviewer.service.MailService; +import com.weatherviewer.service.VerificationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; + +/** + * Default {@link VerificationService} implementation. + *

      + * Tokens are 32 bytes of {@link SecureRandom} output, URL-safe + * base64-encoded — long and unguessable enough to embed directly in an + * emailed link. Issuing a new token for a given user/purpose first + * invalidates any earlier unused ones of that same type, so only the most + * recently emailed link ever works. + */ +@Service +@Slf4j +public class VerificationServiceImpl implements VerificationService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final UserRepository userRepository; + private final VerificationTokenRepository tokenRepository; + private final MailService mailService; + private final PasswordEncoder passwordEncoder; + private final String baseUrl; + private final long verificationTtlHours; + private final long passwordResetTtlHours; + + public VerificationServiceImpl(UserRepository userRepository, + VerificationTokenRepository tokenRepository, + MailService mailService, + PasswordEncoder passwordEncoder, + @Value("${app.base-url:http://localhost:8080}") String baseUrl, + @Value("${app.verification.token-ttl-hours:24}") long verificationTtlHours, + @Value("${app.password-reset.token-ttl-hours:1}") long passwordResetTtlHours) { + this.userRepository = userRepository; + this.tokenRepository = tokenRepository; + this.mailService = mailService; + this.passwordEncoder = passwordEncoder; + this.baseUrl = baseUrl; + this.verificationTtlHours = verificationTtlHours; + this.passwordResetTtlHours = passwordResetTtlHours; + } + + @Override + @Transactional + public void sendVerificationEmail(User user) { + tokenRepository.invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION); + + String rawToken = issueToken(user, TokenType.EMAIL_VERIFICATION, verificationTtlHours); + + String link = UriComponentsBuilder.fromUriString(baseUrl) + .path("/verify-email") + .queryParam("token", rawToken) + .toUriString(); + + mailService.sendVerificationEmail(user.getEmail(), user.getFirstName(), link); + } + + @Override + @Transactional + public void confirmEmail(String token) { + VerificationToken verificationToken = redeem(token, TokenType.EMAIL_VERIFICATION); + + User user = verificationToken.getUser(); + if (user.getStatus() == UserStatus.PENDING) { + user.setStatus(UserStatus.ACTIVE); + userRepository.save(user); + } + } + + @Override + @Transactional + public void requestPasswordReset(String email) { + userRepository.findByEmail(email).ifPresentOrElse(user -> { + tokenRepository.invalidateActiveTokens(user.getId(), TokenType.PASSWORD_RESET); + + String rawToken = issueToken(user, TokenType.PASSWORD_RESET, passwordResetTtlHours); + String link = UriComponentsBuilder.fromUriString(baseUrl) + .path("/reset-password") + .queryParam("token", rawToken) + .toUriString(); + + mailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), link); + }, () -> log.info("Password reset requested for unregistered email={}", email)); + } + + @Override + @Transactional + public void resetPassword(String token, String newRawPassword) { + VerificationToken verificationToken = redeem(token, TokenType.PASSWORD_RESET); + + User user = verificationToken.getUser(); + user.setPassword(passwordEncoder.encode(newRawPassword)); + userRepository.save(user); + } + + /** Generates, persists, and returns the raw (unhashed) token string to embed in the emailed link. */ + private String issueToken(User user, TokenType type, long ttlHours) { + byte[] randomBytes = new byte[32]; + SECURE_RANDOM.nextBytes(randomBytes); + String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + + VerificationToken verificationToken = new VerificationToken() + .setToken(rawToken) + .setUser(user) + .setType(type) + .setExpiresAt(LocalDateTime.now().plusHours(ttlHours)) + .setUsed(false); + + tokenRepository.save(verificationToken); + return rawToken; + } + + /** Looks up a token by value/type, validates it, and marks it used — throwing if any of that fails. */ + private VerificationToken redeem(String rawToken, TokenType type) { + VerificationToken verificationToken = tokenRepository.findByTokenAndType(rawToken, type) + .orElseThrow(() -> new InvalidTokenException("This link is invalid.")); + + if (!verificationToken.isValid()) { + throw new InvalidTokenException("This link has expired or was already used."); + } + + verificationToken.setUsed(true); + tokenRepository.save(verificationToken); + return verificationToken; + } + +} diff --git a/src/test/java/com/weatherviewer/mapper/UserMapperTest.java b/src/test/java/com/weatherviewer/mapper/UserMapperTest.java index e29898c..012af1f 100644 --- a/src/test/java/com/weatherviewer/mapper/UserMapperTest.java +++ b/src/test/java/com/weatherviewer/mapper/UserMapperTest.java @@ -53,7 +53,7 @@ void fromRecord_mapsAllFields() { } @Test - void fromRecord_setsDefaultStatusActive() { + void fromRecord_setsDefaultStatusPending() { CreateUserDto dto = new CreateUserDto() .setFirstName("John") .setLastName("Doe") @@ -62,7 +62,7 @@ void fromRecord_setsDefaultStatusActive() { User result = mapper.fromRecord(dto); - assertThat(result.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(result.getStatus()).isEqualTo(UserStatus.PENDING); } @Test diff --git a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java new file mode 100644 index 0000000..a7ed306 --- /dev/null +++ b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java @@ -0,0 +1,112 @@ +package com.weatherviewer.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.MailException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class MailServiceImplTest { + + private static final String FROM_ADDRESS = "no-reply@weatherviewer.local"; + + @Mock + private JavaMailSender mailSender; + + private MailServiceImpl service; + + @BeforeEach + void setUp() { + service = new MailServiceImpl(mailSender, FROM_ADDRESS); + } + + @Test + void sendVerificationEmail_sendsMessageWithExpectedFromToAndSubject() { + service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + SimpleMailMessage message = captor.getValue(); + assertThat(message.getFrom()).isEqualTo(FROM_ADDRESS); + assertThat(message.getTo()).containsExactly("john@example.com"); + assertThat(message.getSubject()).isEqualTo("Verify your WeatherViewer account"); + } + + @Test + void sendVerificationEmail_bodyContainsGreetingAndLink() { + service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + String body = captor.getValue().getText(); + assertThat(body).contains("Hi John,"); + assertThat(body).contains("https://weatherviewer.local/verify-email?token=abc"); + assertThat(body).contains("expires in 24 hours"); + } + + @Test + void sendPasswordResetEmail_sendsMessageWithExpectedFromToAndSubject() { + service.sendPasswordResetEmail("jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + SimpleMailMessage message = captor.getValue(); + assertThat(message.getFrom()).isEqualTo(FROM_ADDRESS); + assertThat(message.getTo()).containsExactly("jane@example.com"); + assertThat(message.getSubject()).isEqualTo("Reset your WeatherViewer password"); + } + + @Test + void sendPasswordResetEmail_bodyContainsGreetingAndLink() { + service.sendPasswordResetEmail("jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + String body = captor.getValue().getText(); + assertThat(body).contains("Hi Jane,"); + assertThat(body).contains("https://weatherviewer.local/reset-password?token=xyz"); + assertThat(body).contains("expires in 1 hour"); + } + + @Test + void sendVerificationEmail_mailSenderThrows_exceptionIsSwallowed() { + doThrow(new MailSendException("SMTP unreachable")).when(mailSender).send(any(SimpleMailMessage.class)); + + assertThatCode(() -> service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify")) + .doesNotThrowAnyException(); + } + + @Test + void sendPasswordResetEmail_mailSenderThrows_exceptionIsSwallowed() { + doThrow(new MailSendException("SMTP unreachable")).when(mailSender).send(any(SimpleMailMessage.class)); + + assertThatCode(() -> service.sendPasswordResetEmail("jane@example.com", "Jane", "https://weatherviewer.local/reset")) + .doesNotThrowAnyException(); + } + + @Test + void sendVerificationEmail_genericMailException_exceptionIsSwallowed() { + doThrow(new MailException("boom") { + }).when(mailSender).send(any(SimpleMailMessage.class)); + + assertThatCode(() -> service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify")) + .doesNotThrowAnyException(); + } + +} diff --git a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java new file mode 100644 index 0000000..147a062 --- /dev/null +++ b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java @@ -0,0 +1,299 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.model.User; +import com.weatherviewer.model.VerificationToken; +import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.TokenType; +import com.weatherviewer.model.enums.UserStatus; +import com.weatherviewer.repository.UserRepository; +import com.weatherviewer.repository.VerificationTokenRepository; +import com.weatherviewer.service.MailService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VerificationServiceImplTest { + + private static final String BASE_URL = "https://weatherviewer.local"; + private static final long VERIFICATION_TTL_HOURS = 24; + private static final long PASSWORD_RESET_TTL_HOURS = 1; + + @Mock + private UserRepository userRepository; + + @Mock + private VerificationTokenRepository tokenRepository; + + @Mock + private MailService mailService; + + @Mock + private PasswordEncoder passwordEncoder; + + private VerificationServiceImpl service; + + @BeforeEach + void setUp() { + service = new VerificationServiceImpl(userRepository, tokenRepository, mailService, passwordEncoder, + BASE_URL, VERIFICATION_TTL_HOURS, PASSWORD_RESET_TTL_HOURS); + } + + private User user(UUID id) { + return (User) new User() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setPassword("hashed") + .setStatus(UserStatus.PENDING) + .setRole(Role.USER) + .setId(id); + } + + private VerificationToken tokenFor(User owner, TokenType type, String value, boolean used, LocalDateTime expiresAt) { + return (VerificationToken) new VerificationToken() + .setToken(value) + .setType(type) + .setUser(owner) + .setUsed(used) + .setExpiresAt(expiresAt) + .setId(UUID.randomUUID()); + } + + @Test + void sendVerificationEmail_invalidatesPriorTokensAndPersistsNewOne() { + User user = user(UUID.randomUUID()); + + service.sendVerificationEmail(user); + + verify(tokenRepository).invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + + VerificationToken saved = captor.getValue(); + assertThat(saved.getUser()).isEqualTo(user); + assertThat(saved.getType()).isEqualTo(TokenType.EMAIL_VERIFICATION); + assertThat(saved.isUsed()).isFalse(); + assertThat(saved.getToken()).isNotBlank(); + assertThat(saved.getExpiresAt()).isAfter(LocalDateTime.now().plusHours(VERIFICATION_TTL_HOURS - 1)); + assertThat(saved.getExpiresAt()).isBefore(LocalDateTime.now().plusHours(VERIFICATION_TTL_HOURS + 1)); + } + + @Test + void sendVerificationEmail_sendsMailWithLinkContainingBaseUrlAndToken() { + User user = user(UUID.randomUUID()); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(VerificationToken.class); + service.sendVerificationEmail(user); + verify(tokenRepository).save(tokenCaptor.capture()); + String rawToken = tokenCaptor.getValue().getToken(); + + ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); + verify(mailService).sendVerificationEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); + + String link = linkCaptor.getValue(); + assertThat(link).startsWith(BASE_URL + "/verify-email?token="); + assertThat(link).contains(rawToken); + } + + @Test + void sendVerificationEmail_generatesDifferentTokenEachCall() { + User user = user(UUID.randomUUID()); + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + + service.sendVerificationEmail(user); + service.sendVerificationEmail(user); + + verify(tokenRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getToken()).isNotEqualTo(captor.getAllValues().get(1).getToken()); + } + + @Test + void confirmEmail_validToken_activatesPendingUserAndMarksTokenUsed() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", false, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + service.confirmEmail("raw-token"); + + assertThat(token.isUsed()).isTrue(); + assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE); + verify(tokenRepository).save(token); + verify(userRepository).save(user); + } + + @Test + void confirmEmail_userAlreadyActive_doesNotResaveUser() { + User user = user(UUID.randomUUID()); + user.setStatus(UserStatus.ACTIVE); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", false, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + service.confirmEmail("raw-token"); + + assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void confirmEmail_unknownToken_throwsInvalidTokenException() { + when(tokenRepository.findByTokenAndType("bogus", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.confirmEmail("bogus")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link is invalid."); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void confirmEmail_expiredToken_throwsInvalidTokenException() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", false, + LocalDateTime.now().minusMinutes(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.confirmEmail("raw-token")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link has expired or was already used."); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void confirmEmail_alreadyUsedToken_throwsInvalidTokenException() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", true, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.confirmEmail("raw-token")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link has expired or was already used."); + } + + @Test + void confirmEmail_passwordResetTypeToken_notFoundAsEmailVerification() { + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.confirmEmail("raw-token")) + .isInstanceOf(InvalidTokenException.class); + + verify(tokenRepository, never()).findByTokenAndType("raw-token", TokenType.PASSWORD_RESET); + } + + @Test + void requestPasswordReset_existingEmail_invalidatesPriorTokensAndSendsMail() { + User user = user(UUID.randomUUID()); + when(userRepository.findByEmail("john@example.com")).thenReturn(Optional.of(user)); + + service.requestPasswordReset("john@example.com"); + + verify(tokenRepository).invalidateActiveTokens(user.getId(), TokenType.PASSWORD_RESET); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(tokenCaptor.capture()); + assertThat(tokenCaptor.getValue().getType()).isEqualTo(TokenType.PASSWORD_RESET); + assertThat(tokenCaptor.getValue().getExpiresAt()) + .isBefore(LocalDateTime.now().plusHours(PASSWORD_RESET_TTL_HOURS + 1)); + + ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); + verify(mailService).sendPasswordResetEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); + assertThat(linkCaptor.getValue()).startsWith(BASE_URL + "/reset-password?token="); + } + + @Test + void requestPasswordReset_unregisteredEmail_doesNothingSilently() { + when(userRepository.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + service.requestPasswordReset("nobody@example.com"); + + verify(tokenRepository, never()).invalidateActiveTokens(any(UUID.class), any(TokenType.class)); + verify(tokenRepository, never()).save(any(VerificationToken.class)); + verify(mailService, never()).sendPasswordResetEmail(anyString(), anyString(), anyString()); + } + + @Test + void resetPassword_validToken_encodesAndSavesNewPasswordAndMarksTokenUsed() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.PASSWORD_RESET, "raw-token", false, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.of(token)); + when(passwordEncoder.encode("NewSecure1@")).thenReturn("new-hashed"); + + service.resetPassword("raw-token", "NewSecure1@"); + + assertThat(token.isUsed()).isTrue(); + assertThat(user.getPassword()).isEqualTo("new-hashed"); + verify(tokenRepository).save(token); + verify(userRepository).save(user); + } + + @Test + void resetPassword_unknownToken_throwsInvalidTokenExceptionAndNeverTouchesPassword() { + when(tokenRepository.findByTokenAndType("bogus", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.resetPassword("bogus", "NewSecure1@")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link is invalid."); + + verify(passwordEncoder, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void resetPassword_expiredToken_throwsInvalidTokenExceptionAndNeverTouchesPassword() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.PASSWORD_RESET, "raw-token", false, + LocalDateTime.now().minusMinutes(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.resetPassword("raw-token", "NewSecure1@")) + .isInstanceOf(InvalidTokenException.class); + + verify(passwordEncoder, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void resetPassword_alreadyUsedToken_throwsInvalidTokenExceptionAndNeverTouchesPassword() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.PASSWORD_RESET, "raw-token", true, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.resetPassword("raw-token", "NewSecure1@")) + .isInstanceOf(InvalidTokenException.class); + + verify(passwordEncoder, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + +} From 2dbf75823d2b3640949122aebeabad0f988610ba Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 12:00:07 +0300 Subject: [PATCH 32/62] feat(auth): add email verification, password reset endpoints, and UI integration --- pom.xml | 4 + .../weatherviewer/config/SecurityConfig.java | 24 +-- .../controller/AuthController.java | 69 +++++++- .../controller/PasswordResetController.java | 93 +++++++++++ .../exception/MvcExceptionHandler.java | 3 +- .../security/CustomAuthFailureHandler.java | 34 ++++ .../resources/templates/forgot-password.html | 44 +++++ .../resources/templates/reset-password.html | 49 ++++++ src/main/resources/templates/sign-in.html | 10 ++ src/main/resources/templates/sign-up.html | 1 + .../controller/AuthControllerTest.java | 120 ++++++++++++++ .../PasswordResetControllerTest.java | 155 ++++++++++++++++++ .../UserRegistrationIntegrationTest.java | 2 +- .../CustomAuthFailureHandlerTest.java | 84 ++++++++++ 14 files changed, 673 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/weatherviewer/controller/PasswordResetController.java create mode 100644 src/main/resources/templates/forgot-password.html create mode 100644 src/main/resources/templates/reset-password.html create mode 100644 src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java create mode 100644 src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java diff --git a/pom.xml b/pom.xml index 7437962..f37e03d 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,10 @@ io.micrometer micrometer-tracing-bridge-brave + + org.springframework.boot + spring-boot-starter-mail + org.projectlombok lombok diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index 85c8a1a..af0cfb5 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -55,6 +55,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers( "/sign-in", "/sign-up", "/sign-in-failure", + "/verify-email", "/resend-verification", + "/forgot-password", "/reset-password", "/css/**", "/images/**", "/js/**", "/actuator/health", "/actuator/health/**" ).permitAll() @@ -89,18 +91,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .expiredUrl("/sign-in?expired") ) .headers(headers -> headers - .contentSecurityPolicy(csp -> csp.policyDirectives( - "default-src 'self'; " - + "script-src 'self' https://cdn.jsdelivr.net; " - + "style-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com 'unsafe-inline'; " - + "font-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com data:; " - + "img-src 'self' data:; " - + "connect-src 'self'; " - + "object-src 'none'; " - + "base-uri 'self'; " - + "form-action 'self'; " - + "frame-ancestors 'none'" - )) + .contentSecurityPolicy(csp -> csp + .policyDirectives( + "default-src 'self'; " + + "script-src 'self' https://cdn.jsdelivr.net; " + + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; " + + "connect-src 'self' https://cdn.jsdelivr.net; " + + "img-src 'self' data: https:;" + ) + ) ) .addFilterAfter(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/weatherviewer/controller/AuthController.java b/src/main/java/com/weatherviewer/controller/AuthController.java index 25619b4..a0f1eb1 100644 --- a/src/main/java/com/weatherviewer/controller/AuthController.java +++ b/src/main/java/com/weatherviewer/controller/AuthController.java @@ -1,8 +1,11 @@ package com.weatherviewer.controller; import com.weatherviewer.dto.CreateUserDto; +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.model.User; import com.weatherviewer.service.LoginService; import com.weatherviewer.service.UserService; +import com.weatherviewer.service.VerificationService; import com.weatherviewer.utils.SafeRedirectUtils; import jakarta.servlet.ServletException; import jakarta.validation.Valid; @@ -17,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import java.util.UUID; + /** * Thymeleaf controller for the sign-in/sign-up pages and their form * submissions. Successful sign-up auto-logs the new user in via @@ -30,6 +35,7 @@ public class AuthController { private final UserService userService; private final LoginService loginService; + private final VerificationService verificationService; /** Renders the sign-in form, preserving a sanitized post-login redirect target if one was supplied. */ @GetMapping("/sign-in") @@ -42,8 +48,18 @@ public String signIn(@RequestParam(required = false) String redirect, Model mode /** Landing target Spring Security redirects to after a failed login attempt; re-renders sign-in with an error. */ @GetMapping("/sign-in-failure") - public String signInFailure(RedirectAttributes redirectAttributes) { - log.info("Sign-in failed"); + public String signInFailure(@RequestParam(required = false) Boolean unverified, + @RequestParam(required = false) String email, + RedirectAttributes redirectAttributes) { + log.info("Sign-in failed, unverified={}", unverified); + + if (Boolean.TRUE.equals(unverified)) { + redirectAttributes.addFlashAttribute("errorMessage", + "Please verify your email before signing in."); + return "redirect:/sign-in?unverified=true" + + (email != null && !email.isBlank() ? "&email=" + email : ""); + } + redirectAttributes.addFlashAttribute("errorMessage", "Invalid email or password. Please try again."); return "redirect:/sign-in"; } @@ -76,14 +92,19 @@ public String processSignUp(@Valid @ModelAttribute("user") CreateUserDto createU } log.info("Processing sign-up for email={}", createUserDto.getEmail()); - userService.create(createUserDto); + UUID userId = userService.create(createUserDto); + + User createdUser = userService.getEntityById(userId); + verificationService.sendVerificationEmail(createdUser); try { loginService.login(createUserDto.getEmail(), createUserDto.getPassword()); log.info("Auto login successful for email={}", createUserDto.getEmail()); } catch (ServletException e) { - log.warn("Auto login failed after sign-up for email={}", createUserDto.getEmail(), e); - redirectAttributes.addFlashAttribute("errorMessage", "Auto login failed after sign-up"); + log.info("Auto login skipped/failed after sign-up for email={} (account likely pending email verification)", + createUserDto.getEmail()); + redirectAttributes.addFlashAttribute("successMessage", + "Account created! Check your email for a link to verify your account before signing in."); return "redirect:/sign-in"; } @@ -92,4 +113,42 @@ public String processSignUp(@Valid @ModelAttribute("user") CreateUserDto createU return "redirect:" + SafeRedirectUtils.sanitize(redirect, "/"); } + /** Redeems an emailed email-verification link, activating the account, then sends the user to sign in. */ + @GetMapping("/verify-email") + public String verifyEmail(@RequestParam String token, RedirectAttributes redirectAttributes) { + try { + verificationService.confirmEmail(token); + log.info("Email verified successfully for token"); + redirectAttributes.addFlashAttribute("successMessage", "Your email has been verified. You can now sign in."); + } catch (InvalidTokenException e) { + log.warn("Email verification failed: {}", e.getMessage()); + redirectAttributes.addFlashAttribute("errorMessage", + e.getMessage() + " Please request a new verification email."); + } + return "redirect:/sign-in"; + } + + /** + * Re-sends the verification email for an account that hasn't confirmed + * its address yet. Always shows the same confirmation message + * regardless of whether the email exists or is already verified, so + * this can't be used to enumerate registered addresses. + */ + @PostMapping("/resend-verification") + public String resendVerification(@RequestParam String email, RedirectAttributes redirectAttributes) { + log.info("Resend verification requested for email={}", email); + try { + User user = userService.getEntityByEmail(email); + if (user.getStatus() == com.weatherviewer.model.enums.UserStatus.PENDING) { + verificationService.sendVerificationEmail(user); + } + } catch (RuntimeException e) { + log.info("Resend verification requested for unknown email={}", email); + } + + redirectAttributes.addFlashAttribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address."); + return "redirect:/sign-in"; + } + } diff --git a/src/main/java/com/weatherviewer/controller/PasswordResetController.java b/src/main/java/com/weatherviewer/controller/PasswordResetController.java new file mode 100644 index 0000000..60b58c7 --- /dev/null +++ b/src/main/java/com/weatherviewer/controller/PasswordResetController.java @@ -0,0 +1,93 @@ +package com.weatherviewer.controller; + +import com.weatherviewer.dto.ForgotPasswordDto; +import com.weatherviewer.dto.ResetPasswordDto; +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.service.VerificationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +/** + * Thymeleaf controller for the "forgot your password" flow: requesting a + * reset link by email, then redeeming that link to set a new password. + *

      + * The request step always shows the same confirmation message whether or + * not the email is registered, so this flow can't be used to enumerate + * which addresses have accounts. + */ +@Controller +@RequiredArgsConstructor +@Slf4j +public class PasswordResetController { + + private static final String GENERIC_REQUEST_MESSAGE = + "If an account exists for that email, we've sent a link to reset your password."; + + private final VerificationService verificationService; + + /** Renders the "forgot password" email-entry form. */ + @GetMapping("/forgot-password") + public String forgotPassword(Model model) { + model.addAttribute("forgotPasswordDto", new ForgotPasswordDto()); + return "forgot-password"; + } + + /** Issues a reset token for the given email (if it exists) and always shows the same confirmation. */ + @PostMapping("/forgot-password") + public String processForgotPassword(@Valid @ModelAttribute("forgotPasswordDto") ForgotPasswordDto dto, + BindingResult bindingResult, + RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + return "forgot-password"; + } + + log.info("Password reset requested for email={}", dto.getEmail()); + verificationService.requestPasswordReset(dto.getEmail()); + + redirectAttributes.addFlashAttribute("successMessage", GENERIC_REQUEST_MESSAGE); + return "redirect:/sign-in"; + } + + /** Renders the "choose a new password" form for a token carried in the link. */ + @GetMapping("/reset-password") + public String resetPassword(@RequestParam String token, Model model) { + model.addAttribute("resetPasswordDto", new ResetPasswordDto().setToken(token)); + return "reset-password"; + } + + /** Redeems the token and sets the new password, or re-renders the form with an error if the token/password is invalid. */ + @PostMapping("/reset-password") + public String processResetPassword(@Valid @ModelAttribute("resetPasswordDto") ResetPasswordDto dto, + BindingResult bindingResult, + RedirectAttributes redirectAttributes, + Model model) { + if (!bindingResult.hasErrors() && !dto.getPassword().equals(dto.getRepeatPassword())) { + bindingResult.rejectValue("repeatPassword", "password.mismatch", "Passwords do not match"); + } + + if (bindingResult.hasErrors()) { + return "reset-password"; + } + + try { + verificationService.resetPassword(dto.getToken(), dto.getPassword()); + } catch (InvalidTokenException e) { + log.warn("Password reset failed: {}", e.getMessage()); + model.addAttribute("errorMessage", e.getMessage() + " Please request a new reset link."); + return "reset-password"; + } + + redirectAttributes.addFlashAttribute("successMessage", "Your password has been reset. You can now sign in."); + return "redirect:/sign-in"; + } + +} diff --git a/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java b/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java index ddab126..513fd0d 100644 --- a/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java +++ b/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java @@ -25,7 +25,8 @@ SearchController.class, ForecastController.class, ProfileController.class, - AuthController.class + AuthController.class, + PasswordResetController.class }) @Slf4j public class MvcExceptionHandler { diff --git a/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java index 73c9d8f..29ccbbf 100644 --- a/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java +++ b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java @@ -1,12 +1,27 @@ package com.weatherviewer.security; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; /** * Redirects failed sign-in attempts to a dedicated {@code /sign-in-failure} * page instead of Spring Security's default behavior of re-rendering the * login page with a generic query parameter. + *

      + * A {@link DisabledException} means the account exists and the password + * was correct, but {@link com.weatherviewer.security.SecUser#isEnabled()} + * is {@code false} — in practice, an account still {@code PENDING} email + * verification. That case is distinguished from ordinary bad-credentials + * failures via an {@code unverified} query parameter, so the sign-in page + * can offer to resend the verification email instead of just "try again". */ @Component public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler { @@ -15,4 +30,23 @@ public CustomAuthFailureHandler() { super("/sign-in-failure"); } + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + if (!(exception instanceof DisabledException)) { + super.onAuthenticationFailure(request, response, exception); + return; + } + + UriComponentsBuilder targetUrl = UriComponentsBuilder.fromPath("/sign-in-failure") + .queryParam("unverified", "true"); + + String email = request.getParameter("email"); + if (email != null && !email.isBlank()) { + targetUrl.queryParam("email", email); + } + + getRedirectStrategy().sendRedirect(request, response, targetUrl.toUriString()); + } + } diff --git a/src/main/resources/templates/forgot-password.html b/src/main/resources/templates/forgot-password.html new file mode 100644 index 0000000..e325bdf --- /dev/null +++ b/src/main/resources/templates/forgot-password.html @@ -0,0 +1,44 @@ + + + + + + + + + + + +

      +
      +
      +
      +
      +

      Forgot password

      +

      Enter the email address on your account and we'll send you a link to reset your password.

      + + +
      + + +
      +
      +
      + + +
      + +
      +
      +
      + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/reset-password.html b/src/main/resources/templates/reset-password.html new file mode 100644 index 0000000..64aa10e --- /dev/null +++ b/src/main/resources/templates/reset-password.html @@ -0,0 +1,49 @@ + + + + + + + + + + + +
      +
      +
      +
      +
      +

      Reset password

      + +
      + +
      + + +
      +
      +
      + + +
      +
      +
      + + +
      +
      +
      +
      +
      +
      + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/sign-in.html b/src/main/resources/templates/sign-in.html index 4857a56..2cf9ef1 100644 --- a/src/main/resources/templates/sign-in.html +++ b/src/main/resources/templates/sign-in.html @@ -26,6 +26,9 @@

      Sign in

      +
      @@ -36,6 +39,12 @@

      Sign in

      + +
      +

      Didn't get a verification email?

      + + +

      @@ -45,6 +54,7 @@

      Sign in

      + \ No newline at end of file diff --git a/src/main/resources/templates/sign-up.html b/src/main/resources/templates/sign-up.html index 2fae285..e149a90 100644 --- a/src/main/resources/templates/sign-up.html +++ b/src/main/resources/templates/sign-up.html @@ -56,6 +56,7 @@

      Sign up

      + \ No newline at end of file diff --git a/src/test/java/com/weatherviewer/controller/AuthControllerTest.java b/src/test/java/com/weatherviewer/controller/AuthControllerTest.java index bbf1a71..ad8c08f 100644 --- a/src/test/java/com/weatherviewer/controller/AuthControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/AuthControllerTest.java @@ -1,8 +1,12 @@ package com.weatherviewer.controller; +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.exception.notfound.UserNotFoundException; +import com.weatherviewer.model.User; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.service.LoginService; import com.weatherviewer.service.UserService; +import com.weatherviewer.service.VerificationService; import com.weatherviewer.validation.validator.PasswordMatchesValidator; import com.weatherviewer.validation.validator.UniqueEmailValidator; import jakarta.servlet.ServletException; @@ -34,6 +38,9 @@ class AuthControllerTest { @MockitoBean LoginService loginService; + @MockitoBean + VerificationService verificationService; + @MockitoBean UserRepository userRepository; @@ -181,4 +188,117 @@ void processSignUp_autoLoginFails_redirectsToSignIn() throws Exception { .andExpect(redirectedUrl("/sign-in")); } + @Test + @WithMockUser + void verifyEmail_validToken_verifiesAndRedirectsToSignInWithSuccess() throws Exception { + doNothing().when(verificationService).confirmEmail("valid-token"); + + mockMvc.perform(get("/verify-email").param("token", "valid-token")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", "Your email has been verified. You can now sign in.")); + + verify(verificationService).confirmEmail("valid-token"); + } + + @Test + @WithMockUser + void verifyEmail_invalidOrExpiredToken_redirectsToSignInWithError() throws Exception { + doThrow(new InvalidTokenException("Invalid or expired verification token")) + .when(verificationService).confirmEmail("invalid-token"); + + mockMvc.perform(get("/verify-email").param("token", "invalid-token")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("errorMessage", + "Invalid or expired verification token Please request a new verification email.")); + + verify(verificationService).confirmEmail("invalid-token"); + } + + @Test + @WithMockUser + void resendVerification_pendingUser_sendsVerificationEmailAndRedirects() throws Exception { + User pendingUser = new User(); + pendingUser.setStatus(com.weatherviewer.model.enums.UserStatus.PENDING); + when(userService.getEntityByEmail("john@example.com")).thenReturn(pendingUser); + + mockMvc.perform(post("/resend-verification") + .with(csrf()) + .param("email", "john@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address.")); + + verify(verificationService).sendVerificationEmail(pendingUser); + } + + @Test + @WithMockUser + void resendVerification_activeUser_doesNotSendEmailAndRedirectsWithSameMessage() throws Exception { + User activeUser = new User(); + activeUser.setStatus(com.weatherviewer.model.enums.UserStatus.ACTIVE); + when(userService.getEntityByEmail("active@example.com")).thenReturn(activeUser); + + mockMvc.perform(post("/resend-verification") + .with(csrf()) + .param("email", "active@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address.")); + + verify(verificationService, never()).sendVerificationEmail(any()); + } + + @Test + @WithMockUser + void resendVerification_unknownEmail_doesNotSendEmailAndRedirectsWithSameMessage() throws Exception { + when(userService.getEntityByEmail("unknown@example.com")) + .thenThrow(new UserNotFoundException("User not found")); + + mockMvc.perform(post("/resend-verification") + .with(csrf()) + .param("email", "unknown@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address.")); + + verify(verificationService, never()).sendVerificationEmail(any()); + } + + @Test + @WithMockUser + void signInFailure_unverifiedTrue_withEmail_redirectsWithUnverifiedAndEmailQueryParam() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("unverified", "true") + .param("email", "john@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?unverified=true&email=john@example.com")) + .andExpect(flash().attribute("errorMessage", "Please verify your email before signing in.")); + } + + @Test + @WithMockUser + void signInFailure_unverifiedTrue_withoutEmail_redirectsWithUnverifiedQueryParamOnly() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("unverified", "true")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?unverified=true")) + .andExpect(flash().attribute("errorMessage", "Please verify your email before signing in.")); + } + + @Test + @WithMockUser + void signInFailure_unverifiedTrue_withBlankEmail_redirectsWithUnverifiedQueryParamOnly() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("unverified", "true") + .param("email", " ")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?unverified=true")) + .andExpect(flash().attribute("errorMessage", "Please verify your email before signing in.")); + } + } diff --git a/src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java b/src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java new file mode 100644 index 0000000..6e04986 --- /dev/null +++ b/src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java @@ -0,0 +1,155 @@ +package com.weatherviewer.controller; + +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.service.VerificationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.*; + +@WebMvcTest(PasswordResetController.class) +class PasswordResetControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + VerificationService verificationService; + + @Test + @WithMockUser + void forgotPassword_returns200AndView() throws Exception { + mockMvc.perform(get("/forgot-password")) + .andExpect(status().isOk()) + .andExpect(view().name("forgot-password")) + .andExpect(model().attributeExists("forgotPasswordDto")); + } + + @Test + @WithMockUser + void processForgotPassword_validationErrors_returnsFormView() throws Exception { + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "")) + .andExpect(status().isOk()) + .andExpect(view().name("forgot-password")); + + verify(verificationService, never()).requestPasswordReset(anyString()); + } + + @Test + @WithMockUser + void processForgotPassword_invalidEmailFormat_returnsFormView() throws Exception { + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "not-an-email")) + .andExpect(status().isOk()) + .andExpect(view().name("forgot-password")); + + verify(verificationService, never()).requestPasswordReset(anyString()); + } + + @Test + @WithMockUser + void processForgotPassword_success_redirectsToSignInWithGenericMessage() throws Exception { + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "john@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")); + + verify(verificationService).requestPasswordReset("john@example.com"); + } + + @Test + @WithMockUser + void processForgotPassword_unregisteredEmail_stillShowsGenericSuccessRedirect() throws Exception { + doNothing().when(verificationService).requestPasswordReset("nobody@example.com"); + + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "nobody@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")); + + verify(verificationService).requestPasswordReset("nobody@example.com"); + } + + @Test + @WithMockUser + void resetPassword_returns200AndViewWithTokenPrefilled() throws Exception { + mockMvc.perform(get("/reset-password").param("token", "raw-token")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")) + .andExpect(model().attributeExists("resetPasswordDto")); + } + + @Test + @WithMockUser + void processResetPassword_validationErrors_returnsFormView() throws Exception { + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "raw-token") + .param("password", "") + .param("repeatPassword", "")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")); + + verify(verificationService, never()).resetPassword(anyString(), anyString()); + } + + @Test + @WithMockUser + void processResetPassword_passwordsDoNotMatch_returnsFormView() throws Exception { + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "raw-token") + .param("password", "Secure1@") + .param("repeatPassword", "Different1@")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")); + + verify(verificationService, never()).resetPassword(anyString(), anyString()); + } + + @Test + @WithMockUser + void processResetPassword_invalidToken_returnsFormViewWithError() throws Exception { + doThrow(new InvalidTokenException("This link has expired or was already used.")) + .when(verificationService).resetPassword("bad-token", "Secure1@"); + + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "bad-token") + .param("password", "Secure1@") + .param("repeatPassword", "Secure1@")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")) + .andExpect(model().attributeExists("errorMessage")); + } + + @Test + @WithMockUser + void processResetPassword_success_redirectsToSignIn() throws Exception { + doNothing().when(verificationService).resetPassword("raw-token", "Secure1@"); + + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "raw-token") + .param("password", "Secure1@") + .param("repeatPassword", "Secure1@")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")); + + verify(verificationService).resetPassword("raw-token", "Secure1@"); + } + +} diff --git a/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java b/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java index 91d4eb7..5f0eeec 100644 --- a/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java @@ -113,7 +113,7 @@ void signUp_newUserHasDefaultRoleAndStatus() throws Exception { User savedUser = userRepository.findByEmail("defaults@example.com").orElseThrow(); assertThat(savedUser.getRole().name()).isEqualTo("USER"); - assertThat(savedUser.getStatus().name()).isEqualTo("ACTIVE"); + assertThat(savedUser.getStatus().name()).isEqualTo("PENDING"); } } diff --git a/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java b/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java new file mode 100644 index 0000000..60b8339 --- /dev/null +++ b/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java @@ -0,0 +1,84 @@ +package com.weatherviewer.security; + +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.AuthenticationException; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +class CustomAuthFailureHandlerTest { + + private CustomAuthFailureHandler failureHandler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + failureHandler = new CustomAuthFailureHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void onAuthenticationFailure_badCredentials_delegatesToDefaultUrlWithoutUnverifiedParam() + throws IOException, ServletException { + AuthenticationException exception = new BadCredentialsException("Invalid credentials"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_redirectsWithUnverifiedAndEmailParam() + throws IOException, ServletException { + request.setParameter("email", "john@example.com"); + AuthenticationException exception = new DisabledException("User account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true&email=john@example.com", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_withoutEmail_redirectsWithUnverifiedParamOnly() + throws IOException, ServletException { + AuthenticationException exception = new DisabledException("User account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_withoutEmail_redirectsWithoutEmailParam() + throws IOException, ServletException { + AuthenticationException exception = new DisabledException("Account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_withBlankEmail_redirectsWithoutEmailParam() + throws IOException, ServletException { + request.setParameter("email", " "); + AuthenticationException exception = new DisabledException("Account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true", response.getRedirectedUrl()); + } + +} From cb9130d7f20a84b90367eab17944522847a3c788 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 22 Jul 2026 09:14:06 +0300 Subject: [PATCH 33/62] db: add persistent_logins table for remember-me tokens --- src/main/resources/liquibase/changelog.xml | 1 + .../update/06-add-persistent-logins.xml | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/main/resources/liquibase/update/06-add-persistent-logins.xml diff --git a/src/main/resources/liquibase/changelog.xml b/src/main/resources/liquibase/changelog.xml index 0a4206f..51be9e7 100644 --- a/src/main/resources/liquibase/changelog.xml +++ b/src/main/resources/liquibase/changelog.xml @@ -8,5 +8,6 @@ + diff --git a/src/main/resources/liquibase/update/06-add-persistent-logins.xml b/src/main/resources/liquibase/update/06-add-persistent-logins.xml new file mode 100644 index 0000000..1824135 --- /dev/null +++ b/src/main/resources/liquibase/update/06-add-persistent-logins.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From d9b06b6e4a517491808ff1ef09864dc45093a6f0 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 22 Jul 2026 09:16:29 +0300 Subject: [PATCH 34/62] config: add remember-me key and token validity properties --- src/main/resources/application.properties | 15 ++++++++++++--- src/test/resources/application.properties | 4 ++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f273614..1e10548 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -20,6 +20,10 @@ spring.jpa.properties.hibernate.jdbc.time_zone=UTC server.servlet.session.cookie.secure=true server.servlet.session.cookie.same-site=strict +# --- Remember-me --- +security.remember-me.key=${REMEMBER_ME_KEY} +security.remember-me.token-validity-seconds=1209600 + spring.data.web.pageable.default-page-size=20 spring.data.web.pageable.max-page-size=100 spring.data.web.pageable.one-indexed-parameters=false @@ -74,13 +78,18 @@ app.base-url=${APP_BASE_URL:http://localhost:8080} app.verification.token-ttl-hours=24 app.password-reset.token-ttl-hours=1 -# --- Actuator / health --- -management.endpoints.web.exposure.include=health +# --- Actuator / health / metrics --- +management.server.port=${MANAGEMENT_SERVER_PORT:8081} +management.endpoints.web.exposure.include=health,prometheus,info management.endpoint.health.show-details=when-authorized management.endpoint.health.probes.enabled=true management.health.livenessstate.enabled=true management.health.readinessstate.enabled=true +management.metrics.tags.application=${spring.application.name} +management.metrics.distribution.percentiles-histogram.http.server.requests=true +management.metrics.distribution.slo.http.server.requests=50ms,100ms,200ms,500ms,1s,2s + # --- Graceful shutdown --- server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=20s @@ -112,4 +121,4 @@ rate-limit.rules[4].limit=60 rate-limit.rules[4].window-seconds=60 management.tracing.sampling.probability=1.0 -logging.pattern.correlation=[${spring.application.name:-},%X{traceId:-},%X{spanId:-}] +logging.pattern.correlation=[${spring.application.name:-},%X{traceId:-},%X{spanId:-}] \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index a674d26..f175cc6 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -22,3 +22,7 @@ spring.cache.type=simple # --- Mail --- spring.mail.host=localhost spring.mail.port=1025 + +# --- Remember-me --- +security.remember-me.key=test-remember-me-key-not-for-production +security.remember-me.token-validity-seconds=1209600 \ No newline at end of file From f37a925d0bc6b0131d44eadbdf8fb602734ec588 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 22 Jul 2026 09:16:48 +0300 Subject: [PATCH 35/62] feat(ui): add remember-me checkbox to sign-in form --- src/main/resources/templates/sign-in.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/templates/sign-in.html b/src/main/resources/templates/sign-in.html index 2cf9ef1..c64bd4d 100644 --- a/src/main/resources/templates/sign-in.html +++ b/src/main/resources/templates/sign-in.html @@ -26,7 +26,11 @@

      Sign in

      -
      +
      +
      + + +
      Forgot password?
      From 7747d2fd4a64a4b30304e65d1318a092ae224e15 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 22 Jul 2026 09:17:13 +0300 Subject: [PATCH 36/62] feat(auth): wire up persistent-token remember-me --- pom.xml | 4 ++ .../weatherviewer/config/SecurityConfig.java | 64 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index f37e03d..3d85ea4 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,10 @@ io.micrometer micrometer-tracing-bridge-brave + + io.micrometer + micrometer-registry-prometheus + org.springframework.boot spring-boot-starter-mail diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index af0cfb5..39530b6 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -4,6 +4,7 @@ import com.weatherviewer.security.CustomAuthFailureHandler; import com.weatherviewer.security.CustomAuthSuccessHandler; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -13,12 +14,18 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import javax.sql.DataSource; + /** * Core Spring Security configuration. *

      @@ -38,6 +45,21 @@ public class SecurityConfig { private final CustomAuthSuccessHandler customAuthSuccessHandler; private final CustomAuthFailureHandler customAuthFailureHandler; private final RateLimitingFilter rateLimitingFilter; + private final DataSource dataSource; + private final UserDetailsService userDetailsService; + + /** + * Secret used to sign remember-me tokens. Must be a long, random, + * environment-specific value (e.g. {@code openssl rand -hex 32}) — + * never left at a default in production, since anyone who knows it + * could forge a valid remember-me cookie. + */ + @Value("${security.remember-me.key}") + private String rememberMeKey; + + /** How long a remember-me cookie stays valid before the user must sign in again. Defaults to 14 days. */ + @Value("${security.remember-me.token-validity-seconds:1209600}") + private int rememberMeTokenValiditySeconds; /** * Defines the security filter chain: public routes (sign-in/up, static @@ -81,7 +103,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .logoutUrl("/sign-out") .invalidateHttpSession(true) .clearAuthentication(true) - .deleteCookies("JSESSIONID") + .deleteCookies("JSESSIONID", "remember-me") + .addLogoutHandler(rememberMeServices()) .logoutSuccessUrl("/sign-in") ) .sessionManagement(session -> session @@ -90,13 +113,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionRegistry(sessionRegistry()) .expiredUrl("/sign-in?expired") ) + .rememberMe(rememberMe -> rememberMe + .rememberMeServices(rememberMeServices()) + .key(rememberMeKey) + ) .headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives( "default-src 'self'; " + "script-src 'self' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + - "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; " + + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://use.fontawesome.com https://cdnjs.cloudflare.com; " + "connect-src 'self' https://cdn.jsdelivr.net; " + "img-src 'self' data: https:;" ) @@ -132,4 +159,37 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } + /** + * Backs remember-me tokens with the {@code persistent_logins} table + * (schema defined in the {@code 06-add-persistent-logins.xml} Liquibase + * changeset) instead of Spring Security's simpler hash-based cookie. + * This lets a stolen/leaked cookie be invalidated server-side and + * rotates the token on every use, so a captured cookie is only valid + * for a single re-use before it's detected and the whole series is + * revoked. + */ + @Bean + public PersistentTokenRepository persistentTokenRepository() { + JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl(); + repository.setDataSource(dataSource); + return repository; + } + + /** + * Issues and validates the {@code remember-me} cookie. Token validity + * is configurable via {@code security.remember-me.token-validity-seconds} + * (default 14 days); the signing key comes from + * {@code security.remember-me.key} and must be set per-environment. + */ + @Bean + public PersistentTokenBasedRememberMeServices rememberMeServices() { + PersistentTokenBasedRememberMeServices services = + new PersistentTokenBasedRememberMeServices(rememberMeKey, userDetailsService, persistentTokenRepository()); + services.setTokenValiditySeconds(rememberMeTokenValiditySeconds); + services.setParameter("remember-me"); + services.setCookieName("remember-me"); + services.setUseSecureCookie(true); + return services; + } + } From 61451518c7d5c557042ff77e39750f426bae1a2b Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 22 Jul 2026 09:18:29 +0300 Subject: [PATCH 37/62] chore(docker): expose management port, point healthcheck at it --- Dockerfile | 4 ++-- docker-compose.yml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c1ac92a..fd006b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY --from=build /app/target/*.jar app.jar RUN chown -R appuser:appgroup /app USER appuser -EXPOSE 8080 +EXPOSE 8080 8081 HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:8081/actuator/health || exit 1 ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:+UseContainerSupport", "-jar", "app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index 7cccee4..0d395dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,14 @@ services: restart: always ports: - "8080:8080" + - "8081:8081" env_file: - .env environment: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/weather SPRING_DATA_REDIS_HOST: redis healthcheck: - test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health" ] + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8081/actuator/health" ] interval: 15s timeout: 5s retries: 3 From a332e54fc376a2e21859df1d3cbd33c73bef9a11 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 23 Jul 2026 11:10:00 +0300 Subject: [PATCH 38/62] build: add resilience4j-spring-boot3 dependency --- pom.xml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pom.xml b/pom.xml index 3d85ea4..8d50792 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ 6.6.7.Final 1.0.1.Final 0.8.12 + 2.2.0 1.5.5.Final 0.2.0 @@ -102,6 +103,15 @@ org.springframework.boot spring-boot-starter-mail + + org.springframework.boot + spring-boot-starter-aop + + + io.github.resilience4j + resilience4j-spring-boot3 + ${resilience4j.version} + org.projectlombok lombok @@ -214,6 +224,26 @@ report + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.90 + + + + + + From 27d2e4c36a0f4d56f06e76accbc5b370b12bbc26 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 23 Jul 2026 11:10:55 +0300 Subject: [PATCH 39/62] db: add failed_login_attempts and locked_until columns to users --- .../java/com/weatherviewer/model/User.java | 18 +++ .../security/AccountLockoutListener.java | 94 +++++++++++ .../com/weatherviewer/security/SecUser.java | 16 +- src/main/resources/liquibase/changelog.xml | 1 + .../update/07-add-account-lockout.xml | 13 ++ .../security/AccountLockoutListenerTest.java | 151 ++++++++++++++++++ .../weatherviewer/security/SecUserTest.java | 28 ++++ 7 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/weatherviewer/security/AccountLockoutListener.java create mode 100644 src/main/resources/liquibase/update/07-add-account-lockout.xml create mode 100644 src/test/java/com/weatherviewer/security/AccountLockoutListenerTest.java diff --git a/src/main/java/com/weatherviewer/model/User.java b/src/main/java/com/weatherviewer/model/User.java index 24b87f7..69755be 100644 --- a/src/main/java/com/weatherviewer/model/User.java +++ b/src/main/java/com/weatherviewer/model/User.java @@ -11,6 +11,7 @@ import org.hibernate.annotations.JdbcType; import org.hibernate.dialect.PostgreSQLEnumJdbcType; +import java.time.LocalDateTime; import java.util.List; /** @@ -57,6 +58,23 @@ public class User extends BaseEntity { @JdbcType(PostgreSQLEnumJdbcType.class) private UnitSystem units = UnitSystem.METRIC; + /** + * Consecutive failed sign-in attempts since the last successful login + * or the last time the account was unlocked. Reset to zero on every + * successful authentication. Tracked by + * {@link com.weatherviewer.security.AccountLockoutListener}. + */ + @Column(nullable = false) + private int failedLoginAttempts = 0; + + /** + * If set and still in the future, the account is temporarily locked out + * of authentication regardless of {@link #status} — see + * {@link com.weatherviewer.security.SecUser#isAccountNonLocked()}. + * {@code null} means the account isn't locked. + */ + private LocalDateTime lockedUntil; + /** Locations saved by this user; removed automatically if the user is deleted. */ @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List locations; diff --git a/src/main/java/com/weatherviewer/security/AccountLockoutListener.java b/src/main/java/com/weatherviewer/security/AccountLockoutListener.java new file mode 100644 index 0000000..5894c8d --- /dev/null +++ b/src/main/java/com/weatherviewer/security/AccountLockoutListener.java @@ -0,0 +1,94 @@ +package com.weatherviewer.security; + +import com.weatherviewer.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * Tracks consecutive failed sign-in attempts per account and applies a + * temporary lockout once a threshold is reached, mitigating credential + * stuffing / brute-force attempts against a single known email address — + * something IP- or session-based rate limiting alone doesn't fully cover, + * since an attacker can spread attempts across many IPs. + *

      + * Listens to the {@link AuthenticationFailureBadCredentialsEvent} and + * {@link AuthenticationSuccessEvent} that Spring Security's default + * {@code AuthenticationEventPublisher} raises around every + * {@code DaoAuthenticationProvider} attempt. Once an account is locked, + * {@link SecUser#isAccountNonLocked()} makes subsequent attempts fail with + * {@code LockedException} instead of {@code BadCredentialsException} — so + * this listener naturally stops incrementing further while the lock is in + * effect, rather than perpetually extending it. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AccountLockoutListener { + + private final UserRepository userRepository; + + /** Consecutive failures before an account is locked. */ + @Value("${security.account-lockout.max-attempts:5}") + private int maxAttempts; + + /** How long an account stays locked once the threshold is hit. */ + @Value("${security.account-lockout.lock-duration-minutes:15}") + private long lockDurationMinutes; + + /** + * On a failed login with valid credentials-format-but-wrong-password + * (or, thanks to {@code hideUserNotFoundExceptions}, an unknown email + * too — though in that case there's no matching row to update), + * increments the account's failure counter and locks it once + * {@link #maxAttempts} is reached. + */ + @EventListener + @Transactional + public void onAuthenticationFailure(AuthenticationFailureBadCredentialsEvent event) { + String email = event.getAuthentication().getName(); + userRepository.findByEmail(email).ifPresent(user -> { + int attempts = user.getFailedLoginAttempts() + 1; + user.setFailedLoginAttempts(attempts); + + if (attempts >= maxAttempts) { + user.setLockedUntil(LocalDateTime.now().plusMinutes(lockDurationMinutes)); + log.warn("Account locked for {} minutes after {} consecutive failed sign-in attempts: {}", + lockDurationMinutes, attempts, email); + } else { + log.debug("Failed sign-in attempt {}/{} for {}", attempts, maxAttempts, email); + } + + userRepository.save(user); + }); + } + + /** + * Clears the failure counter and any active lockout on a successful + * sign-in, so a legitimate user who mistyped their password a few + * times isn't left partway toward a lockout on their next visit. + */ + @EventListener + @Transactional + public void onAuthenticationSuccess(AuthenticationSuccessEvent event) { + if (!(event.getAuthentication().getPrincipal() instanceof SecUser secUser)) { + return; + } + + userRepository.findById(secUser.getId()).ifPresent(user -> { + if (user.getFailedLoginAttempts() != 0 || user.getLockedUntil() != null) { + user.setFailedLoginAttempts(0); + user.setLockedUntil(null); + userRepository.save(user); + } + }); + } + +} diff --git a/src/main/java/com/weatherviewer/security/SecUser.java b/src/main/java/com/weatherviewer/security/SecUser.java index 54854f1..7520886 100644 --- a/src/main/java/com/weatherviewer/security/SecUser.java +++ b/src/main/java/com/weatherviewer/security/SecUser.java @@ -9,6 +9,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import java.time.LocalDateTime; import java.util.Collection; import java.util.Set; import java.util.UUID; @@ -17,11 +18,14 @@ * Spring Security {@link UserDetails} adapter around this application's * {@link User} entity. *

      - * All four account-state checks ({@code isAccountNonExpired}, - * {@code isAccountNonLocked}, {@code isCredentialsNonExpired}, - * {@code isEnabled}) are backed by the single {@link #isActive} flag — + * {@code isAccountNonExpired}, {@code isCredentialsNonExpired}, and + * {@code isEnabled} are all backed by the single {@link #isActive} flag — * this app doesn't distinguish between those states, so any non-{@code ACTIVE} * {@link UserStatus} simply locks the account out of authentication. + * {@code isAccountNonLocked} is separate: it reflects the temporary, + * self-clearing lockout applied after repeated failed sign-in attempts + * (see {@link com.weatherviewer.security.AccountLockoutListener}), not the + * account's persistent {@link UserStatus}. */ @Getter @RequiredArgsConstructor @@ -34,6 +38,7 @@ public class SecUser implements UserDetails { private final Boolean isActive; private final String fullName; private final UnitSystem units; + private final LocalDateTime lockedUntil; @Override public Collection getAuthorities() { @@ -57,7 +62,7 @@ public boolean isAccountNonExpired() { @Override public boolean isAccountNonLocked() { - return isActive; + return isActive && (lockedUntil == null || lockedUntil.isBefore(LocalDateTime.now())); } @Override @@ -84,7 +89,8 @@ public static SecUser fromUser(User user) { user.getRole().getAuthority(), user.getStatus() == UserStatus.ACTIVE, user.getFullName(), - user.getUnits() + user.getUnits(), + user.getLockedUntil() ); } diff --git a/src/main/resources/liquibase/changelog.xml b/src/main/resources/liquibase/changelog.xml index 51be9e7..e5d9933 100644 --- a/src/main/resources/liquibase/changelog.xml +++ b/src/main/resources/liquibase/changelog.xml @@ -9,5 +9,6 @@ + diff --git a/src/main/resources/liquibase/update/07-add-account-lockout.xml b/src/main/resources/liquibase/update/07-add-account-lockout.xml new file mode 100644 index 0000000..57d4c82 --- /dev/null +++ b/src/main/resources/liquibase/update/07-add-account-lockout.xml @@ -0,0 +1,13 @@ + + + + + + ALTER TABLE users ADD COLUMN failed_login_attempts INT NOT NULL DEFAULT 0; + ALTER TABLE users ADD COLUMN locked_until TIMESTAMP NULL; + + + + diff --git a/src/test/java/com/weatherviewer/security/AccountLockoutListenerTest.java b/src/test/java/com/weatherviewer/security/AccountLockoutListenerTest.java new file mode 100644 index 0000000..651eabd --- /dev/null +++ b/src/test/java/com/weatherviewer/security/AccountLockoutListenerTest.java @@ -0,0 +1,151 @@ +package com.weatherviewer.security; + +import com.weatherviewer.model.User; +import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.UserStatus; +import com.weatherviewer.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AccountLockoutListenerTest { + + private static final int MAX_ATTEMPTS = 5; + private static final long LOCK_DURATION_MINUTES = 15; + + @Mock + private UserRepository userRepository; + + private AccountLockoutListener listener; + + @BeforeEach + void setUp() { + listener = new AccountLockoutListener(userRepository); + ReflectionTestUtils.setField(listener, "maxAttempts", MAX_ATTEMPTS); + ReflectionTestUtils.setField(listener, "lockDurationMinutes", LOCK_DURATION_MINUTES); + } + + private User user(int failedAttempts) { + User user = (User) new User() + .setEmail("john@example.com") + .setPassword("hashed") + .setStatus(UserStatus.ACTIVE) + .setRole(Role.USER) + .setId(UUID.randomUUID()); + user.setFailedLoginAttempts(failedAttempts); + return user; + } + + @Test + void onAuthenticationFailure_belowThreshold_incrementsWithoutLocking() { + User user = user(1); + when(userRepository.findByEmail("john@example.com")).thenReturn(Optional.of(user)); + + listener.onAuthenticationFailure(failureEvent("john@example.com")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getFailedLoginAttempts()).isEqualTo(2); + assertThat(captor.getValue().getLockedUntil()).isNull(); + } + + @Test + void onAuthenticationFailure_reachesThreshold_locksAccount() { + User user = user(MAX_ATTEMPTS - 1); + when(userRepository.findByEmail("john@example.com")).thenReturn(Optional.of(user)); + + listener.onAuthenticationFailure(failureEvent("john@example.com")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getFailedLoginAttempts()).isEqualTo(MAX_ATTEMPTS); + assertThat(captor.getValue().getLockedUntil()) + .isAfter(LocalDateTime.now().plusMinutes(LOCK_DURATION_MINUTES).minusSeconds(5)); + } + + @Test + void onAuthenticationFailure_unknownEmail_doesNothing() { + when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty()); + + listener.onAuthenticationFailure(failureEvent("ghost@example.com")); + + verify(userRepository, never()).save(any()); + } + + @Test + void onAuthenticationSuccess_clearsCounterAndLock() { + User user = user(3); + user.setLockedUntil(LocalDateTime.now().minusMinutes(1)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + listener.onAuthenticationSuccess(successEvent(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getFailedLoginAttempts()).isZero(); + assertThat(captor.getValue().getLockedUntil()).isNull(); + } + + @Test + void onAuthenticationSuccess_noPriorFailures_doesNotWriteToDatabase() { + User user = user(0); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + listener.onAuthenticationSuccess(successEvent(user)); + + verify(userRepository, never()).save(any()); + } + + @Test + void onAuthenticationSuccess_principalNotSecUser_doesNothing() { + Authentication authentication = new UsernamePasswordAuthenticationToken("anonymousUser", null); + AuthenticationSuccessEvent event = new AuthenticationSuccessEvent(authentication); + + listener.onAuthenticationSuccess(event); + + verify(userRepository, never()).findById(any()); + verify(userRepository, never()).save(any()); + } + + @Test + void onAuthenticationSuccess_attemptsZeroButLockedUntilSet_resetsAndSaves() { + User user = user(0); + user.setLockedUntil(LocalDateTime.now().minusMinutes(1)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + listener.onAuthenticationSuccess(successEvent(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getFailedLoginAttempts()).isZero(); + assertThat(captor.getValue().getLockedUntil()).isNull(); + } + + private AuthenticationFailureBadCredentialsEvent failureEvent(String email) { + Authentication authentication = new UsernamePasswordAuthenticationToken(email, "wrong-password"); + return new AuthenticationFailureBadCredentialsEvent(authentication, new org.springframework.security.authentication.BadCredentialsException("bad credentials")); + } + + private AuthenticationSuccessEvent successEvent(User user) { + SecUser secUser = SecUser.fromUser(user); + Authentication authentication = new UsernamePasswordAuthenticationToken(secUser, null, secUser.getAuthorities()); + return new AuthenticationSuccessEvent(authentication); + } + +} diff --git a/src/test/java/com/weatherviewer/security/SecUserTest.java b/src/test/java/com/weatherviewer/security/SecUserTest.java index 7a41525..dcbbcb0 100644 --- a/src/test/java/com/weatherviewer/security/SecUserTest.java +++ b/src/test/java/com/weatherviewer/security/SecUserTest.java @@ -5,6 +5,7 @@ import com.weatherviewer.model.enums.UserStatus; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -80,4 +81,31 @@ void fromUser_userRole_doesNotHaveWriteAuthority() { .doesNotContain("users:write"); } + @Test + void isAccountNonLocked_lockedUntilInFuture_returnsFalse() { + User user = user(UserStatus.ACTIVE, Role.USER); + user.setLockedUntil(LocalDateTime.now().plusMinutes(15)); + SecUser secUser = SecUser.fromUser(user); + + assertThat(secUser.isAccountNonLocked()).isFalse(); + } + + @Test + void isAccountNonLocked_lockedUntilInPast_returnsTrue() { + User user = user(UserStatus.ACTIVE, Role.USER); + user.setLockedUntil(LocalDateTime.now().minusMinutes(5)); + SecUser secUser = SecUser.fromUser(user); + + assertThat(secUser.isAccountNonLocked()).isTrue(); + } + + @Test + void isAccountNonLocked_inactiveStatusWithNullLockedUntil_returnsFalse() { + User user = user(UserStatus.PENDING, Role.USER); + user.setLockedUntil(null); + SecUser secUser = SecUser.fromUser(user); + + assertThat(secUser.isAccountNonLocked()).isFalse(); + } + } From 517a5dd7da1555f05a2ee2789ef0a456e5672bf9 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 23 Jul 2026 11:12:06 +0300 Subject: [PATCH 40/62] feat(security): lock account after repeated failed sign-in attempts --- .../com/weatherviewer/config/AppConfig.java | 20 ++++++++++++++--- .../controller/AuthController.java | 13 ++++++++--- .../exception/ExternalHttpCallException.java | 22 +++++++++++++++++++ .../security/CustomAuthFailureHandler.java | 19 +++++++++++++++- .../controller/AuthControllerTest.java | 21 ++++++++++++++++++ 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/weatherviewer/config/AppConfig.java b/src/main/java/com/weatherviewer/config/AppConfig.java index 01dee09..262fc7c 100644 --- a/src/main/java/com/weatherviewer/config/AppConfig.java +++ b/src/main/java/com/weatherviewer/config/AppConfig.java @@ -1,7 +1,9 @@ package com.weatherviewer.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.RedirectStrategy; import org.springframework.web.client.RestClient; @@ -17,10 +19,22 @@ @Configuration public class AppConfig { - /** HTTP client used by {@link com.weatherviewer.service.integration.WeatherApiClient} to call OpenWeatherMap. */ + /** + * HTTP client used by {@link com.weatherviewer.service.integration.WeatherApiClient} to + * call OpenWeatherMap. Explicit connect/read timeouts are required here: without them the + * underlying JDK client factory has no default timeout, so a hung or slow OpenWeatherMap + * response would block the calling request thread indefinitely and, under load, exhaust + * the server's thread pool. + */ @Bean - public RestClient restClient(RestClient.Builder restClientBuilder) { - return restClientBuilder.build(); + public RestClient restClient( + RestClient.Builder restClientBuilder, + @Value("${weather.api.connect-timeout-ms:3000}") int connectTimeoutMs, + @Value("${weather.api.read-timeout-ms:5000}") int readTimeoutMs) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(connectTimeoutMs); + requestFactory.setReadTimeout(readTimeoutMs); + return restClientBuilder.requestFactory(requestFactory).build(); } /** Default Spring Security redirect strategy, used by {@link com.weatherviewer.security.CustomAuthSuccessHandler}. */ diff --git a/src/main/java/com/weatherviewer/controller/AuthController.java b/src/main/java/com/weatherviewer/controller/AuthController.java index a0f1eb1..6d59f52 100644 --- a/src/main/java/com/weatherviewer/controller/AuthController.java +++ b/src/main/java/com/weatherviewer/controller/AuthController.java @@ -49,15 +49,22 @@ public String signIn(@RequestParam(required = false) String redirect, Model mode /** Landing target Spring Security redirects to after a failed login attempt; re-renders sign-in with an error. */ @GetMapping("/sign-in-failure") public String signInFailure(@RequestParam(required = false) Boolean unverified, + @RequestParam(required = false) Boolean locked, @RequestParam(required = false) String email, RedirectAttributes redirectAttributes) { - log.info("Sign-in failed, unverified={}", unverified); + log.info("Sign-in failed, unverified={}, locked={}", unverified, locked); + String emailParam = email != null && !email.isBlank() ? "&email=" + email : ""; if (Boolean.TRUE.equals(unverified)) { redirectAttributes.addFlashAttribute("errorMessage", "Please verify your email before signing in."); - return "redirect:/sign-in?unverified=true" - + (email != null && !email.isBlank() ? "&email=" + email : ""); + return "redirect:/sign-in?unverified=true" + emailParam; + } + + if (Boolean.TRUE.equals(locked)) { + redirectAttributes.addFlashAttribute("errorMessage", + "Too many failed sign-in attempts. Your account is temporarily locked — please try again in a few minutes."); + return "redirect:/sign-in?locked=true" + emailParam; } redirectAttributes.addFlashAttribute("errorMessage", "Invalid email or password. Please try again."); diff --git a/src/main/java/com/weatherviewer/exception/ExternalHttpCallException.java b/src/main/java/com/weatherviewer/exception/ExternalHttpCallException.java index b0142e8..2a84363 100644 --- a/src/main/java/com/weatherviewer/exception/ExternalHttpCallException.java +++ b/src/main/java/com/weatherviewer/exception/ExternalHttpCallException.java @@ -1,5 +1,7 @@ package com.weatherviewer.exception; +import lombok.Getter; + /** * Thrown when a call to an external HTTP service fails — in practice, the * OpenWeatherMap API accessed by @@ -8,13 +10,33 @@ * {@link ControllerExceptionHandler} into a {@code 503 Service Unavailable} * response. */ +@Getter public class ExternalHttpCallException extends RuntimeException { + /** + * Whether this failure is worth retrying. Network errors, timeouts, and + * 5xx responses from the provider are transient and {@code retryable}; + * 4xx responses (bad request, unauthorized, not found) reflect a + * problem with the request itself and won't succeed on a second try, so + * they're marked {@code false} and skipped by + * {@link com.weatherviewer.service.integration.WeatherApiRetryPredicate}. + */ + private final boolean retryable; + /** * @param message description of what went wrong calling the external service */ public ExternalHttpCallException(String message) { + this(message, true); + } + + /** + * @param message description of what went wrong calling the external service + * @param retryable whether a retry might succeed + */ + public ExternalHttpCallException(String message, boolean retryable) { super(message); + this.retryable = retryable; } } diff --git a/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java index 29ccbbf..7d66e64 100644 --- a/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java +++ b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; @@ -22,6 +23,12 @@ * verification. That case is distinguished from ordinary bad-credentials * failures via an {@code unverified} query parameter, so the sign-in page * can offer to resend the verification email instead of just "try again". + *

      + * A {@link LockedException} means {@link com.weatherviewer.security.SecUser#isAccountNonLocked()} + * is {@code false} — {@link com.weatherviewer.security.AccountLockoutListener} + * locked the account after too many recent failed attempts. That's + * distinguished via a {@code locked} query parameter, so the sign-in page + * can tell the user to wait rather than implying their password is wrong. */ @Component public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler { @@ -33,13 +40,23 @@ public CustomAuthFailureHandler() { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + if (exception instanceof LockedException) { + redirectWithParam(request, response, "locked"); + return; + } + if (!(exception instanceof DisabledException)) { super.onAuthenticationFailure(request, response, exception); return; } + redirectWithParam(request, response, "unverified"); + } + + private void redirectWithParam(HttpServletRequest request, HttpServletResponse response, String param) + throws IOException { UriComponentsBuilder targetUrl = UriComponentsBuilder.fromPath("/sign-in-failure") - .queryParam("unverified", "true"); + .queryParam(param, "true"); String email = request.getParameter("email"); if (email != null && !email.isBlank()) { diff --git a/src/test/java/com/weatherviewer/controller/AuthControllerTest.java b/src/test/java/com/weatherviewer/controller/AuthControllerTest.java index ad8c08f..63d79a4 100644 --- a/src/test/java/com/weatherviewer/controller/AuthControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/AuthControllerTest.java @@ -301,4 +301,25 @@ void signInFailure_unverifiedTrue_withBlankEmail_redirectsWithUnverifiedQueryPar .andExpect(flash().attribute("errorMessage", "Please verify your email before signing in.")); } + @Test + @WithMockUser + void signInFailure_lockedTrue_withEmail_redirectsWithLockedAndEmailQueryParam() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("locked", "true") + .param("email", "john@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?locked=true&email=john@example.com")) + .andExpect(flash().attributeExists("errorMessage")); + } + + @Test + @WithMockUser + void signInFailure_lockedTrue_withoutEmail_redirectsWithLockedQueryParamOnly() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("locked", "true")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?locked=true")) + .andExpect(flash().attributeExists("errorMessage")); + } + } From a31a1a94fb419f86271f46214d293c50e9fe849e Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 23 Jul 2026 11:12:22 +0300 Subject: [PATCH 41/62] feat(resilience): add WeatherApiRetryPredicate for weatherApi retries --- .../integration/WeatherApiRetryPredicate.java | 30 +++++++++++++++++++ .../WeatherApiRetryPredicateTest.java | 29 ++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/main/java/com/weatherviewer/service/integration/WeatherApiRetryPredicate.java create mode 100644 src/test/java/com/weatherviewer/service/integration/WeatherApiRetryPredicateTest.java diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiRetryPredicate.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiRetryPredicate.java new file mode 100644 index 0000000..a678045 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiRetryPredicate.java @@ -0,0 +1,30 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.exception.ExternalHttpCallException; + +import java.util.function.Predicate; + +/** + * Decides, for the {@code weatherApi} resilience4j retry instance + * (configured in {@code application.properties} via + * {@code resilience4j.retry.instances.weatherApi.retry-exception-predicate}), + * whether a failure from {@link WeatherApiClient} is worth retrying. + *

      + * Transient failures — network errors, timeouts, malformed responses, and + * 5xx status codes — are retried. Client errors (bad request, invalid API + * key, unknown city) are marked non-retryable on the exception itself by + * {@link WeatherApiClient}, since retrying an inherently-wrong request + * three times only adds latency and burns the rate-limited provider quota + * without any chance of succeeding. + */ +public class WeatherApiRetryPredicate implements Predicate { + + @Override + public boolean test(Throwable throwable) { + if (throwable instanceof ExternalHttpCallException externalHttpCallException) { + return externalHttpCallException.isRetryable(); + } + return true; + } + +} diff --git a/src/test/java/com/weatherviewer/service/integration/WeatherApiRetryPredicateTest.java b/src/test/java/com/weatherviewer/service/integration/WeatherApiRetryPredicateTest.java new file mode 100644 index 0000000..983688e --- /dev/null +++ b/src/test/java/com/weatherviewer/service/integration/WeatherApiRetryPredicateTest.java @@ -0,0 +1,29 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.exception.ExternalHttpCallException; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class WeatherApiRetryPredicateTest { + + private final WeatherApiRetryPredicate predicate = new WeatherApiRetryPredicate(); + + @Test + void test_retryableExternalHttpCallException_returnsTrue() { + assertThat(predicate.test(new ExternalHttpCallException("5xx from provider", true))).isTrue(); + } + + @Test + void test_nonRetryableExternalHttpCallException_returnsFalse() { + assertThat(predicate.test(new ExternalHttpCallException("404 unknown city", false))).isFalse(); + } + + @Test + void test_otherExceptionTypes_defaultsToRetryable() { + assertThat(predicate.test(new IOException("connection reset"))).isTrue(); + } + +} From aae505036ac63ad2d7a85f4438a7e1a8e9aeffdd Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 23 Jul 2026 11:13:20 +0300 Subject: [PATCH 42/62] config: configure weatherApi retry, circuit breaker and add unit tests --- .../service/integration/WeatherApiClient.java | 65 +++++- src/main/resources/application.properties | 21 ++ .../controller/ForecastControllerTest.java | 3 +- .../controller/HomeControllerTest.java | 3 +- .../controller/ProfileControllerTest.java | 3 +- .../controller/SearchControllerTest.java | 3 +- .../ExternalHttpCallExceptionTest.java | 15 ++ .../integration/ForecastIntegrationTest.java | 3 +- .../integration/LocationIntegrationTest.java | 3 +- .../integration/ProfileIntegrationTest.java | 3 +- .../integration/SearchIntegrationTest.java | 5 +- .../WeatherApiIntegrationTest.java | 5 +- .../ratelimit/RateLimitingFilterTest.java | 11 +- .../rest/LocationControllerTest.java | 3 +- .../CustomAuthFailureHandlerTest.java | 24 ++- .../integration/WeatherApiClientTest.java | 203 +++++++++++++++++- 16 files changed, 354 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java index 2fef711..64c297b 100644 --- a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java +++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.weatherviewer.exception.ExternalHttpCallException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -51,7 +53,17 @@ public class WeatherApiClient { @Value("${geo.api.url.suffix}") private String geocodingApiUrlSuffix; - /** Fetches current weather for a city by name. */ + /** + * Fetches current weather for a city by name. + *

      + * Wrapped with the {@code weatherApi} retry (transient failures only, + * see {@link WeatherApiRetryPredicate}) and circuit breaker: once the + * provider is failing consistently, the breaker opens and short-circuits + * straight to {@link #fallbackWeatherByCity} instead of piling up more + * slow/failing calls. + */ + @Retry(name = "weatherApi") + @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackWeatherByCity") public JsonNode fetchCurrentWeatherByCity(String city) { log.debug("Building URL and fetching current weather for city: {}", city); String url = buildCityUrl(weatherApiUrlSuffix, city); @@ -59,6 +71,8 @@ public JsonNode fetchCurrentWeatherByCity(String city) { } /** Fetches current weather for a coordinate pair. */ + @Retry(name = "weatherApi") + @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackWeatherByCoordinates") public JsonNode fetchCurrentWeatherByCoordinates(double latitude, double longitude) { log.debug("Building URL and fetching current weather for lat: {}, lon: {}", latitude, longitude); String url = buildCoordsUrl(weatherApiUrlSuffix, latitude, longitude); @@ -66,6 +80,8 @@ public JsonNode fetchCurrentWeatherByCoordinates(double latitude, double longitu } /** Fetches the raw 3-hour-step forecast for a city by name. */ + @Retry(name = "weatherApi") + @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackForecastByCity") public JsonNode fetchForecastByCity(String city) { log.debug("Building URL and fetching forecast for city: {}", city); String url = buildCityUrl(forecastApiUrlSuffix, city); @@ -73,6 +89,8 @@ public JsonNode fetchForecastByCity(String city) { } /** Fetches the raw 3-hour-step forecast for a coordinate pair. */ + @Retry(name = "weatherApi") + @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackForecastByCoordinates") public JsonNode fetchForecastByCoordinates(double latitude, double longitude) { log.debug("Building URL and fetching forecast for lat: {}, lon: {}", latitude, longitude); String url = buildCoordsUrl(forecastApiUrlSuffix, latitude, longitude); @@ -80,12 +98,54 @@ public JsonNode fetchForecastByCoordinates(double latitude, double longitude) { } /** Geocodes a free-text city name into candidate locations. */ + @Retry(name = "weatherApi") + @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackGeocodingByCity") public JsonNode fetchGeocodingByCity(String city) { log.debug("Building URL and fetching geocoding data for city: {}", city); String url = buildCityUrl(geocodingApiUrlSuffix, city); return fetchJsonNode(url); } + /** + * Fallback invoked once retries are exhausted or the {@code weatherApi} + * circuit breaker is open. Resilience4j matches this by parameter list + * (original method's args, plus the triggering {@link Throwable}), so + * each public fetch method needs its own overload here even though the + * bodies are identical. + */ + private JsonNode fallbackWeatherByCity(String city, Throwable t) { + return handleFallback("current weather", "city=" + city, t); + } + + private JsonNode fallbackWeatherByCoordinates(double latitude, double longitude, Throwable t) { + return handleFallback("current weather", "lat=" + latitude + ", lon=" + longitude, t); + } + + private JsonNode fallbackForecastByCity(String city, Throwable t) { + return handleFallback("forecast", "city=" + city, t); + } + + private JsonNode fallbackForecastByCoordinates(double latitude, double longitude, Throwable t) { + return handleFallback("forecast", "lat=" + latitude + ", lon=" + longitude, t); + } + + private JsonNode fallbackGeocodingByCity(String city, Throwable t) { + return handleFallback("geocoding", "city=" + city, t); + } + + /** + * Logs the exhausted call and surfaces a single, consistent + * {@link ExternalHttpCallException} regardless of whether we got here + * via a retryable exception running out of attempts or via an open + * circuit breaker ({@code CallNotPermittedException}). Callers (and + * {@link com.weatherviewer.exception.ControllerExceptionHandler}) only + * ever need to handle one exception type. + */ + private JsonNode handleFallback(String operation, String params, Throwable t) { + log.error("Weather API {} call failed after retries/circuit breaker for {}: {}", operation, params, t.toString()); + throw new ExternalHttpCallException("Weather service is temporarily unavailable, please try again shortly", false); + } + /** * Issues the GET request and parses the response body as JSON. * @@ -104,7 +164,8 @@ private JsonNode fetchJsonNode(String url) { throw new ExternalHttpCallException(message); } catch (RestClientResponseException e) { log.error("Weather API returned {} for URL: {}", e.getStatusCode(), maskApiKey(url), e); - throw new ExternalHttpCallException("Weather API error: " + e.getStatusCode()); + boolean retryable = e.getStatusCode() == null || !e.getStatusCode().is4xxClientError(); + throw new ExternalHttpCallException("Weather API error: " + e.getStatusCode(), retryable); } catch (Exception e) { String message = "External HTTP call failed due to network or connection issues"; log.error("{} for URL: {}", message, maskApiKey(url), e); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1e10548..54482fe 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -24,6 +24,10 @@ server.servlet.session.cookie.same-site=strict security.remember-me.key=${REMEMBER_ME_KEY} security.remember-me.token-validity-seconds=1209600 +# --- Account lockout (protects a single account against distributed brute force) --- +security.account-lockout.max-attempts=${ACCOUNT_LOCKOUT_MAX_ATTEMPTS:5} +security.account-lockout.lock-duration-minutes=${ACCOUNT_LOCKOUT_DURATION_MINUTES:15} + spring.data.web.pageable.default-page-size=20 spring.data.web.pageable.max-page-size=100 spring.data.web.pageable.one-indexed-parameters=false @@ -54,6 +58,23 @@ weather.api.url.suffix=/data/2.5/weather forecast.api.url.suffix=/data/2.5/forecast geo.api.url.suffix=/geo/1.0/direct weather.api.key=${WEATHER_API_KEY} +weather.api.connect-timeout-ms=${WEATHER_API_CONNECT_TIMEOUT_MS:3000} +weather.api.read-timeout-ms=${WEATHER_API_READ_TIMEOUT_MS:5000} + +# --- Resilience (retry + circuit breaker around the OpenWeatherMap client) --- +resilience4j.retry.instances.weatherApi.max-attempts=3 +resilience4j.retry.instances.weatherApi.wait-duration=500ms +resilience4j.retry.instances.weatherApi.enable-exponential-backoff=true +resilience4j.retry.instances.weatherApi.exponential-backoff-multiplier=2 +resilience4j.retry.instances.weatherApi.retry-exception-predicate=com.weatherviewer.service.integration.WeatherApiRetryPredicate + +resilience4j.circuitbreaker.instances.weatherApi.sliding-window-type=COUNT_BASED +resilience4j.circuitbreaker.instances.weatherApi.sliding-window-size=20 +resilience4j.circuitbreaker.instances.weatherApi.minimum-number-of-calls=10 +resilience4j.circuitbreaker.instances.weatherApi.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.weatherApi.wait-duration-in-open-state=30s +resilience4j.circuitbreaker.instances.weatherApi.permitted-number-of-calls-in-half-open-state=5 +resilience4j.circuitbreaker.instances.weatherApi.automatic-transition-from-open-to-half-open-enabled=true # --- Redis / caching --- spring.data.redis.host=${SPRING_DATA_REDIS_HOST:localhost} diff --git a/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java b/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java index 26052a3..65a9963 100644 --- a/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/ForecastControllerTest.java @@ -67,7 +67,8 @@ private SecUser secUser() { Set.of(), true, "John Doe", - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java b/src/test/java/com/weatherviewer/controller/HomeControllerTest.java index f1c0e97..b8d95f1 100644 --- a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/HomeControllerTest.java @@ -48,7 +48,8 @@ private SecUser secUser() { Set.of(), true, "John Doe", - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java b/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java index 4339257..1c10e96 100644 --- a/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/ProfileControllerTest.java @@ -52,7 +52,8 @@ private SecUser secUser() { Set.of(), true, "John Doe", - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/controller/SearchControllerTest.java b/src/test/java/com/weatherviewer/controller/SearchControllerTest.java index 2b030e6..332655a 100644 --- a/src/test/java/com/weatherviewer/controller/SearchControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/SearchControllerTest.java @@ -63,7 +63,8 @@ private SecUser secUser() { Set.of(), true, "John Doe", - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/exception/ExternalHttpCallExceptionTest.java b/src/test/java/com/weatherviewer/exception/ExternalHttpCallExceptionTest.java index 8871dc5..6668070 100644 --- a/src/test/java/com/weatherviewer/exception/ExternalHttpCallExceptionTest.java +++ b/src/test/java/com/weatherviewer/exception/ExternalHttpCallExceptionTest.java @@ -26,4 +26,19 @@ void thrown_canBeCaughtAsRuntimeException() { .hasMessage("External call failed"); } + @Test + void singleArgConstructor_defaultsToRetryable() { + ExternalHttpCallException ex = new ExternalHttpCallException("Timeout"); + assertThat(ex.isRetryable()).isTrue(); + } + + @Test + void twoArgConstructor_setsRetryableFlag() { + ExternalHttpCallException retryable = new ExternalHttpCallException("5xx from provider", true); + ExternalHttpCallException notRetryable = new ExternalHttpCallException("404 unknown city", false); + + assertThat(retryable.isRetryable()).isTrue(); + assertThat(notRetryable.isRetryable()).isFalse(); + } + } diff --git a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java b/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java index 8178220..fc64bde 100644 --- a/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ForecastIntegrationTest.java @@ -58,7 +58,8 @@ void setUp() { Set.of(), true, savedUser.getFullName(), - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java b/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java index 3311e1d..efa8110 100644 --- a/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/LocationIntegrationTest.java @@ -63,7 +63,8 @@ void setUp() { Set.of(), true, savedUser.getFullName(), - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java b/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java index b8d4406..c412a1d 100644 --- a/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/ProfileIntegrationTest.java @@ -57,7 +57,8 @@ void setUp() { Set.of(), true, savedUser.getFullName(), - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java b/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java index 8cad246..64d536b 100644 --- a/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/SearchIntegrationTest.java @@ -70,7 +70,8 @@ void setUp() { Set.of(), true, savedUser.getFullName(), - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } @@ -181,7 +182,7 @@ void addLocation_sameCoordinatesDifferentUser_bothSaved() throws Exception { SecUser otherSecUser = new SecUser( otherUser.getId(), otherUser.getEmail(), otherUser.getPassword(), Set.of(), true, otherUser.getFullName(), - UnitSystem.METRIC + UnitSystem.METRIC, null ); mockMvc.perform(post("/search/add") diff --git a/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java b/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java index e3c916a..9745893 100644 --- a/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/WeatherApiIntegrationTest.java @@ -12,6 +12,7 @@ import org.springframework.cache.CacheManager; import org.springframework.http.MediaType; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.ExpectedCount; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.test.web.client.response.MockRestResponseCreators; import org.springframework.web.client.RestClient; @@ -96,7 +97,7 @@ void getWeatherByCoordinates_realHttpFlow_returnsMappedWeatherDto() { @Test void getWeatherByCity_apiReturnsError_throwsExternalHttpCallException() { - mockServer.expect(method(GET)) + mockServer.expect(ExpectedCount.times(3), method(GET)) .andRespond(MockRestResponseCreators.withServerError()); assertThatThrownBy(() -> weatherApiService.getWeatherByCity("Kyiv")) @@ -107,7 +108,7 @@ void getWeatherByCity_apiReturnsError_throwsExternalHttpCallException() { @Test void getWeatherByCity_malformedJson_throwsExternalHttpCallException() { - mockServer.expect(method(GET)) + mockServer.expect(ExpectedCount.times(3), method(GET)) .andRespond(withSuccess("not-valid-json{{{", MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> weatherApiService.getWeatherByCity("Kyiv")) diff --git a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java index a0253bb..8bb49a9 100644 --- a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java +++ b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java @@ -1,5 +1,6 @@ package com.weatherviewer.ratelimit; +import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.security.SecUser; import jakarta.servlet.FilterChain; import org.junit.jupiter.api.AfterEach; @@ -55,7 +56,15 @@ private RateLimitProperties.Rule rule(String prefix, int limit) { } private void authenticateAs(UUID userId) { - SecUser secUser = new SecUser(userId, "john@example.com", "hashed", java.util.Set.of(), true, "John Doe", com.weatherviewer.model.enums.UnitSystem.METRIC); + SecUser secUser = new SecUser( + userId, + "john@example.com", + "hashed", + java.util.Set.of(), + true, + "John Doe", + UnitSystem.METRIC, + null); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(secUser, null, secUser.getAuthorities())); } diff --git a/src/test/java/com/weatherviewer/rest/LocationControllerTest.java b/src/test/java/com/weatherviewer/rest/LocationControllerTest.java index 6792e51..b2417f8 100644 --- a/src/test/java/com/weatherviewer/rest/LocationControllerTest.java +++ b/src/test/java/com/weatherviewer/rest/LocationControllerTest.java @@ -55,7 +55,8 @@ void setUp() { Set.of(), true, "John Doe", - UnitSystem.METRIC + UnitSystem.METRIC, + null ); } diff --git a/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java b/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java index 60b8339..cae2e38 100644 --- a/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java +++ b/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java @@ -9,6 +9,7 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.AuthenticationException; import java.io.IOException; @@ -71,7 +72,28 @@ void onAuthenticationFailure_disabledException_withoutEmail_redirectsWithoutEmai } @Test - void onAuthenticationFailure_disabledException_withBlankEmail_redirectsWithoutEmailParam() + void onAuthenticationFailure_lockedException_redirectsWithLockedAndEmailParam() + throws IOException, ServletException { + request.setParameter("email", "john@example.com"); + AuthenticationException exception = new LockedException("Account is locked"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?locked=true&email=john@example.com", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_lockedException_withoutEmail_redirectsWithLockedParamOnly() + throws IOException, ServletException { + AuthenticationException exception = new LockedException("Account is locked"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?locked=true", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_withBlankEmail_redirectsWithUnverifiedParamOnly() throws IOException, ServletException { request.setParameter("email", " "); AuthenticationException exception = new DisabledException("Account is disabled"); diff --git a/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java b/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java index eaaef2d..5eb62e7 100644 --- a/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java +++ b/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java @@ -20,6 +20,7 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientResponseException; +import java.lang.reflect.Method; import java.net.URI; import java.util.List; @@ -61,9 +62,9 @@ void setUp() { ReflectionTestUtils.setField(client, "forecastApiUrlSuffix", "/data/2.5/forecast"); ReflectionTestUtils.setField(client, "geocodingApiUrlSuffix", "/geo/1.0/direct"); - doReturn(requestHeadersUriSpec).when(restClient).get(); - doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri(any(URI.class)); - when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + lenient().doReturn(requestHeadersUriSpec).when(restClient).get(); + lenient().doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri(any(URI.class)); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); logAppender = new ListAppender<>(); logAppender.start(); @@ -222,6 +223,202 @@ void fetchJsonNode_errorLogging_masksApiKeyRegardlessOfSuffixOrParamOrder() { assertLoggedMessagesContainNoApiKeyAndAreMasked(); } + @Test + void fetchCurrentWeatherByCity_buildsUrlWithMetricUnitsAndEnglishLang() throws Exception { + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("{}"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchCurrentWeatherByCity("Kyiv"); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).startsWith("https://api.openweathermap.org/data/2.5/weather"); + assertThat(uri).contains("units=metric"); + assertThat(uri).contains("lang=en"); + assertThat(uri).contains("q=Kyiv"); + assertThat(uri).contains("appid=" + SECRET_API_KEY); + } + + @Test + void fetchForecastByCity_buildsUrlWithForecastSuffix() throws Exception { + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("{}"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchForecastByCity("Kyiv"); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).startsWith("https://api.openweathermap.org/data/2.5/forecast"); + assertThat(uri).contains("units=metric"); + assertThat(uri).contains("lang=en"); + assertThat(uri).contains("q=Kyiv"); + } + + @Test + void fetchGeocodingByCity_buildsUrlWithGeoSuffix() throws Exception { + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("[]"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchGeocodingByCity("Kyiv"); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).startsWith("https://api.openweathermap.org/geo/1.0/direct"); + assertThat(uri).contains("q=Kyiv"); + } + + @Test + void fetchCurrentWeatherByCoordinates_buildsUrlWithLatLonAndNoQParam() throws Exception { + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("{}"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchCurrentWeatherByCoordinates(50.45, 30.52); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).contains("lat=50.45"); + assertThat(uri).contains("lon=30.52"); + assertThat(uri).contains("units=metric"); + assertThat(uri).contains("lang=en"); + assertThat(uri).doesNotContain("q="); + } + + @Test + void fetchForecastByCoordinates_buildsUrlWithLatLonAndForecastSuffix() throws Exception { + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("{}"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchForecastByCoordinates(50.45, 30.52); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).startsWith("https://api.openweathermap.org/data/2.5/forecast"); + assertThat(uri).contains("lat=50.45"); + assertThat(uri).contains("lon=30.52"); + } + + @Test + void fetchCurrentWeatherByCoordinates_encodesNegativeAndDecimalCoordinatesCorrectly() throws Exception { + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("{}"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchCurrentWeatherByCoordinates(-33.8688, 151.2093); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).contains("lat=-33.8688"); + assertThat(uri).contains("lon=151.2093"); + assertThat(uri).doesNotContain("%25"); + } + + @Test + void fetchJsonNode_restClientResponseExceptionWith4xxStatus_isNotRetryable() { + RestClientResponseException ex = mock(RestClientResponseException.class); + when(ex.getStatusCode()).thenReturn(org.springframework.http.HttpStatus.NOT_FOUND); + when(responseSpec.body(String.class)).thenThrow(ex); + + assertThatThrownBy(() -> client.fetchCurrentWeatherByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class) + .satisfies(e -> assertThat(((ExternalHttpCallException) e).isRetryable()).isFalse()); + } + + @Test + void fetchJsonNode_restClientResponseExceptionWith5xxStatus_isRetryable() { + RestClientResponseException ex = mock(RestClientResponseException.class); + when(ex.getStatusCode()).thenReturn(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE); + when(responseSpec.body(String.class)).thenThrow(ex); + + assertThatThrownBy(() -> client.fetchCurrentWeatherByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class) + .satisfies(e -> assertThat(((ExternalHttpCallException) e).isRetryable()).isTrue()); + } + + @Test + void fetchJsonNode_restClientResponseExceptionWith429_isRetryable() { + RestClientResponseException ex = mock(RestClientResponseException.class); + when(ex.getStatusCode()).thenReturn(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS); + when(responseSpec.body(String.class)).thenThrow(ex); + + assertThatThrownBy(() -> client.fetchCurrentWeatherByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class) + .satisfies(e -> assertThat(((ExternalHttpCallException) e).isRetryable()).isFalse()); + } + + @Test + void maskApiKey_masksAppidRegardlessOfCaseAndPosition() throws Exception { + Method maskApiKey = WeatherApiClient.class.getDeclaredMethod("maskApiKey", String.class); + maskApiKey.setAccessible(true); + + assertThat((String) maskApiKey.invoke(null, "https://x.com/y?appid=secret123&units=metric")) + .isEqualTo("https://x.com/y?appid=***&units=metric"); + + assertThat((String) maskApiKey.invoke(null, "https://x.com/y?units=metric&appid=secret123")) + .isEqualTo("https://x.com/y?units=metric&appid=***"); + + assertThat((String) maskApiKey.invoke(null, "https://x.com/y?APPID=secret123")) + .isEqualTo("https://x.com/y?APPID=***"); + + assertThat((String) maskApiKey.invoke(null, "https://x.com/y?units=metric&appid=secret123&lang=en")) + .isEqualTo("https://x.com/y?units=metric&appid=***&lang=en"); + } + + @Test + void maskApiKey_leavesUrlUnchangedWhenNoAppidPresent() throws Exception { + Method maskApiKey = WeatherApiClient.class.getDeclaredMethod("maskApiKey", String.class); + maskApiKey.setAccessible(true); + + String url = "https://x.com/y?units=metric&lang=en"; + assertThat((String) maskApiKey.invoke(null, url)).isEqualTo(url); + } + + @Test + void fetchForecastByCity_networkException_throwsExternalHttpCallException() { + when(responseSpec.body(String.class)) + .thenThrow(new RuntimeException("Connection refused")); + + assertThatThrownBy(() -> client.fetchForecastByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class) + .hasMessageContaining("External HTTP call failed"); + } + + @Test + void fetchGeocodingByCity_networkException_throwsExternalHttpCallException() { + when(responseSpec.body(String.class)) + .thenThrow(new RuntimeException("Connection refused")); + + assertThatThrownBy(() -> client.fetchGeocodingByCity("Kyiv")) + .isInstanceOf(ExternalHttpCallException.class) + .hasMessageContaining("External HTTP call failed"); + } + + @Test + void fetchForecastByCoordinates_networkException_throwsExternalHttpCallException() { + when(responseSpec.body(String.class)) + .thenThrow(new RuntimeException("Connection refused")); + + assertThatThrownBy(() -> client.fetchForecastByCoordinates(50.45, 30.52)) + .isInstanceOf(ExternalHttpCallException.class) + .hasMessageContaining("External HTTP call failed"); + } + private void assertLoggedMessagesContainNoApiKeyAndAreMasked() { List events = logAppender.list; assertThat(events).isNotEmpty(); From 61fcd980abb4eebe88e70422cc675bc9f3168584 Mon Sep 17 00:00:00 2001 From: podlLev Date: Fri, 24 Jul 2026 10:16:38 +0300 Subject: [PATCH 43/62] fix(rate-limit): exclude static assets from rate limiting filter --- .../ratelimit/RateLimitingFilter.java | 17 +++++ src/main/resources/application.properties | 6 ++ .../ratelimit/RateLimitingFilterTest.java | 65 +++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java b/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java index db417cc..4f6f190 100644 --- a/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java +++ b/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.util.Comparator; import java.util.List; +import java.util.Set; /** * Servlet filter that enforces per-client request rate limits, backed by @@ -43,6 +44,9 @@ public class RateLimitingFilter extends OncePerRequestFilter { private static final String RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining"; private static final String API_PATH_PREFIX = "/api"; + private static final Set STATIC_PREFIXES = Set.of("/css/", "/js/", "/images/", "/static/"); + private static final Set STATIC_EXTENSIONS = Set.of(".svg", ".png", ".ico", ".css", ".js"); + private final RedisFixedWindowRateLimiter rateLimiter; private final RateLimitProperties properties; private List trustedProxyMatchers; @@ -61,6 +65,19 @@ void initTrustedProxies() { .toList(); } + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + if (path == null) { + return false; + } + + boolean isStaticPrefix = STATIC_PREFIXES.stream().anyMatch(path::startsWith); + boolean isStaticExt = STATIC_EXTENSIONS.stream().anyMatch(path::endsWith); + + return isStaticPrefix || isStaticExt; + } + @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 54482fe..4cc8610 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -52,7 +52,13 @@ scalar.enabled=true scalar.theme=purple scalar.path=/scalar +# --- Locations --- +location.max-per-user=${LOCATION_MAX_PER_USER:100} +location.dashboard.page-size=${LOCATION_DASHBOARD_PAGE_SIZE:4} + # --- Weather API (OpenWeatherMap) --- +weather.dashboard.fetch-pool-size=${WEATHER_DASHBOARD_FETCH_POOL_SIZE:20} +weather.dashboard.fetch-timeout-ms=${WEATHER_DASHBOARD_FETCH_TIMEOUT_MS:4000} weather.base.api.url=https://api.openweathermap.org weather.api.url.suffix=/data/2.5/weather forecast.api.url.suffix=/data/2.5/forecast diff --git a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java index 8bb49a9..7e78901 100644 --- a/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java +++ b/src/test/java/com/weatherviewer/ratelimit/RateLimitingFilterTest.java @@ -3,6 +3,7 @@ import com.weatherviewer.model.enums.UnitSystem; import com.weatherviewer.security.SecUser; import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,6 +19,8 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -545,4 +548,66 @@ void doFilter_withNullPathPrefixInRules_shouldIgnoreItAndFallbackToDefaultRule() eq(properties.getDefaultWindowSeconds())); } + @Test + void shouldNotFilter_nullPath_returnsFalse() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn(null); + + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) + .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 10, 0)); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + verify(rateLimiter).tryConsume(anyString(), anyInt(), anyInt()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotFilter_noMatches_coversFalseBranch() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/weather"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(rateLimiter.tryConsume(anyString(), anyInt(), anyInt())) + .thenReturn(new RedisFixedWindowRateLimiter.RateLimitResult(true, 10, 0)); + + filter.doFilter(request, response, filterChain); + + verify(rateLimiter).tryConsume(anyString(), anyInt(), anyInt()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotFilter_matchingExtensionWithNonStaticPrefix_coversSecondBranch() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/logo.png"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + verifyNoInteractions(rateLimiter); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotFilter_prefixMatch_returnsTrue() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/images/icon"); + assertTrue(filter.shouldNotFilter(request)); + } + + @Test + void shouldNotFilter_extensionMatchOnly_returnsTrue() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/icon.svg"); + assertTrue(filter.shouldNotFilter(request)); + } + + @Test + void shouldNotFilter_noMatch_returnsFalse() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/weather"); + assertFalse(filter.shouldNotFilter(request)); + } + } From c14f3aedd60564ba9b1ceb5f985e0f73a408a880 Mon Sep 17 00:00:00 2001 From: podlLev Date: Fri, 24 Jul 2026 10:17:22 +0300 Subject: [PATCH 44/62] refactor(home): handle per-location weather fetch failures gracefully --- .../com/weatherviewer/config/AppConfig.java | 21 +++ .../controller/HomeController.java | 117 ++++++++++----- src/main/resources/templates/home.html | 22 +++ .../controller/HomeControllerTest.java | 133 +++++++++++++----- 4 files changed, 226 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/weatherviewer/config/AppConfig.java b/src/main/java/com/weatherviewer/config/AppConfig.java index 262fc7c..33df369 100644 --- a/src/main/java/com/weatherviewer/config/AppConfig.java +++ b/src/main/java/com/weatherviewer/config/AppConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.scheduling.concurrent.CustomizableThreadFactory; import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.RedirectStrategy; import org.springframework.web.client.RestClient; @@ -11,6 +12,8 @@ import org.springframework.web.servlet.i18n.SessionLocaleResolver; import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * General-purpose application beans that don't belong to a more specific @@ -51,4 +54,22 @@ public LocaleResolver localeResolver() { return slr; } + /** + * Dedicated, bounded thread pool used by + * {@link com.weatherviewer.controller.HomeController} to fetch weather + * for a user's saved locations concurrently. + *

      + * Deliberately separate from the JVM-wide common {@code ForkJoinPool} + * (what a bare {@code parallelStream()} would use): that pool is shared + * with unrelated parallel streams elsewhere in the JVM and has no + * request-scoped bound, so a user with many saved locations could + * starve it for everyone. Sized via {@code weather.dashboard.fetch-pool-size} + * (default 20) and shut down automatically on context close. + */ + @Bean(destroyMethod = "shutdown") + public ExecutorService weatherFetchExecutor( + @Value("${weather.dashboard.fetch-pool-size:20}") int poolSize) { + return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("weather-fetch-")); + } + } diff --git a/src/main/java/com/weatherviewer/controller/HomeController.java b/src/main/java/com/weatherviewer/controller/HomeController.java index 04c271c..23d935c 100644 --- a/src/main/java/com/weatherviewer/controller/HomeController.java +++ b/src/main/java/com/weatherviewer/controller/HomeController.java @@ -8,16 +8,20 @@ import com.weatherviewer.service.helper.UnitConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.support.RedirectAttributes; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -34,79 +38,128 @@ public class HomeController { private final WeatherApiService weatherApiService; private final LocationService locationService; private final UnitConverter unitConverter; + private final ExecutorService weatherFetchExecutor; + + /** Per-location timeout for the dashboard's concurrent weather fetches. */ + @Value("${weather.dashboard.fetch-timeout-ms:4000}") + private long weatherFetchTimeoutMs; + + /** Locations shown per dashboard page. Not user-adjustable, to keep the weather fan-out per request bounded. */ + @Value("${location.dashboard.page-size:12}") + private int dashboardPageSize; /** - * Renders the dashboard. Locations are fetched pre-sorted by - * {@code sort} ({@code nameAsc}, {@code nameDesc}, {@code favoriteFirst}, - * {@code favoritesOnly}, or the default {@code date}), then current - * weather is fetched for all of them in parallel to keep page load fast - * when a user has many saved locations. + * Renders one page of the dashboard. Locations are fetched pre-sorted + * and pre-paged by {@code sort} ({@code nameAsc}, {@code nameDesc}, + * {@code favoriteFirst}, {@code favoritesOnly}, or the default + * {@code date}) and {@code page} (0-based; page size is fixed via + * {@code location.dashboard.page-size}), then current weather is + * fetched for just that page's locations concurrently on a bounded + * pool ({@link com.weatherviewer.config.AppConfig#weatherFetchExecutor}) + * to keep page load fast without competing with the JVM's shared + * common {@code ForkJoinPool}. + *

      + * Each fetch is capped at {@code weather.dashboard.fetch-timeout-ms} + * and failures are isolated per location: a single slow or failing + * provider call surfaces that one location as unavailable instead of + * failing the whole dashboard. */ @GetMapping("/") public String home(Model model, @AuthenticationPrincipal SecUser user, - @RequestParam(required = false, defaultValue = "date") String sort) { - log.info("Home page requested by user '{}', sort={}", user.getUsername(), sort); - - List userLocations = switch (sort.toLowerCase()) { - case "nameasc" -> locationService.getByUserIdSortedByNameAsc(user.getId()); - case "namedesc" -> locationService.getByUserIdSortedByNameDesc(user.getId()); - case "favoritefirst" -> locationService.getByUserIdSortedByFavorite(user.getId()); - case "favoritesonly" -> locationService.getFavoritesByUserId(user.getId()); - default -> locationService.getByUserIdSortedByDate(user.getId()); - }; - - if (!userLocations.isEmpty()) { - Map locationWeatherMap = userLocations.parallelStream() + @RequestParam(required = false, defaultValue = "date") String sort, + @RequestParam(required = false, defaultValue = "0") int page) { + int safePage = Math.max(page, 0); + log.info("Home page requested by user '{}', sort={}, page={}", user.getUsername(), sort, safePage); + + Page locationPage = locationService.getByUserIdSorted( + user.getId(), sort, PageRequest.of(safePage, dashboardPageSize)); + List pageLocations = locationPage.getContent(); + + if (!pageLocations.isEmpty()) { + Map> pendingWeather = pageLocations.stream() .collect(Collectors.toMap( Function.identity(), - location -> unitConverter.toDisplayUnits( - weatherApiService.getWeatherByLocation(location), user.getUnits()), + location -> CompletableFuture + .supplyAsync(() -> weatherApiService.getWeatherByLocation(location), + weatherFetchExecutor) + .orTimeout(weatherFetchTimeoutMs, TimeUnit.MILLISECONDS), (existing, replacement) -> existing, LinkedHashMap::new )); + + Map locationWeatherMap = new LinkedHashMap<>(); + List unavailableLocationNames = new ArrayList<>(); + + for (Map.Entry> entry : pendingWeather.entrySet()) { + LocationDto location = entry.getKey(); + try { + WeatherDto weather = entry.getValue().join(); + locationWeatherMap.put(location, unitConverter.toDisplayUnits(weather, user.getUnits())); + } catch (CompletionException ex) { + log.warn("Weather fetch failed for location '{}' (user '{}'): {}", + location.getName(), user.getUsername(), ex.getCause() != null + ? ex.getCause().getMessage() : ex.getMessage()); + unavailableLocationNames.add(location.getName()); + } + } + model.addAttribute("locationWeatherMap", locationWeatherMap); - log.info("Weather data prepared for {} locations", locationWeatherMap.size()); + if (!unavailableLocationNames.isEmpty()) { + model.addAttribute("errorMessages", unavailableLocationNames.stream() + .map(name -> "Weather for \"" + name + "\" is temporarily unavailable") + .toList()); + } + log.info("Weather data prepared for {} of {} locations on page {}", + locationWeatherMap.size(), pageLocations.size(), safePage); } model.addAttribute("login", user.getFullName()); model.addAttribute("sort", sort); + model.addAttribute("currentPage", locationPage.getNumber()); + model.addAttribute("totalPages", locationPage.getTotalPages()); + model.addAttribute("totalLocations", locationPage.getTotalElements()); + model.addAttribute("hasPreviousPage", locationPage.hasPrevious()); + model.addAttribute("hasNextPage", locationPage.hasNext()); model.addAttribute("temperatureSymbol", unitConverter.temperatureSymbol(user.getUnits())); model.addAttribute("windSpeedUnit", unitConverter.windSpeedUnit(user.getUnits())); return "home"; } - /** Deletes a saved location (ownership-checked) and redirects back to the dashboard preserving the current sort. */ + /** Deletes a saved location (ownership-checked) and redirects back to the dashboard preserving the current sort/page. */ @DeleteMapping("/locations/{id}") public String deleteLocation(@PathVariable UUID id, @AuthenticationPrincipal SecUser user, RedirectAttributes redirectAttributes, - @RequestParam(required = false, defaultValue = "date") String sort) { + @RequestParam(required = false, defaultValue = "date") String sort, + @RequestParam(required = false, defaultValue = "0") int page) { log.info("User '{}' is deleting location with id={}", user.getUsername(), id); locationService.deleteByIdAndUserId(id, user.getId()); redirectAttributes.addFlashAttribute("successMessage", "Location deleted successfully"); - return "redirect:/?sort=" + sort; + return "redirect:/?sort=" + sort + "&page=" + page; } @PostMapping("/locations/{id}/favorite") public String addToFavorite(@PathVariable UUID id, @AuthenticationPrincipal SecUser user, RedirectAttributes redirectAttributes, - @RequestParam(required = false, defaultValue = "date") String sort) { + @RequestParam(required = false, defaultValue = "date") String sort, + @RequestParam(required = false, defaultValue = "0") int page) { log.info("User {} is adding location {} to favorites", user.getUsername(), id); locationService.addToFavorite(id, user.getId()); redirectAttributes.addFlashAttribute("successMessage", "Location added to favorites"); - return "redirect:/?sort=" + sort; + return "redirect:/?sort=" + sort + "&page=" + page; } @DeleteMapping("/locations/{id}/favorite") public String removeFromFavorite(@PathVariable UUID id, @AuthenticationPrincipal SecUser user, RedirectAttributes redirectAttributes, - @RequestParam(required = false, defaultValue = "date") String sort) { + @RequestParam(required = false, defaultValue = "date") String sort, + @RequestParam(required = false, defaultValue = "0") int page) { log.info("User {} is removing location {} from favorites", user.getUsername(), id); locationService.removeFromFavorite(id, user.getId()); redirectAttributes.addFlashAttribute("successMessage", "Location removed from favorites"); - return "redirect:/?sort=" + sort; + return "redirect:/?sort=" + sort + "&page=" + page; } } diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index bb19669..63695ad 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -45,6 +45,7 @@

      Location

      + @@ -52,6 +53,7 @@

      Location

      + @@ -72,16 +74,19 @@

      Location

      @@ -198,6 +203,23 @@

      Location Required for Weather Information

      Please add a location to view weather. You can do it by using the search bar above.

      + +
      diff --git a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java b/src/test/java/com/weatherviewer/controller/HomeControllerTest.java index b8d95f1..ec5458a 100644 --- a/src/test/java/com/weatherviewer/controller/HomeControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/HomeControllerTest.java @@ -12,6 +12,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -20,6 +24,7 @@ import java.util.Set; import java.util.UUID; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -74,15 +79,20 @@ private WeatherDto weatherDto() { .setDate(new Date()); } + private Page pageOf(List locations, Pageable pageable) { + return new PageImpl<>(locations, pageable, locations.size()); + } + @Test void home_returns200AndHomeView() throws Exception { SecUser secUser = secUser(); - when(locationService.getByUserIdSortedByDate(secUser.getId())).thenReturn(List.of()); + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), any(Pageable.class))) + .thenReturn(pageOf(List.of(), PageRequest.of(0, 12))); mockMvc.perform(get("/").with(user(secUser))) .andExpect(status().isOk()) .andExpect(view().name("home")) - .andExpect(model().attributeExists("login", "sort")); + .andExpect(model().attributeExists("login", "sort", "currentPage", "totalPages")); } @Test @@ -91,7 +101,8 @@ void home_withLocations_addsWeatherMap() throws Exception { LocationDto location = locationDto(); WeatherDto weather = weatherDto(); - when(locationService.getByUserIdSortedByDate(secUser.getId())).thenReturn(List.of(location)); + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), any(Pageable.class))) + .thenReturn(pageOf(List.of(location), PageRequest.of(0, 12))); when(weatherApiService.getWeatherByLocation(location)).thenReturn(weather); mockMvc.perform(get("/").with(user(secUser))) @@ -100,62 +111,78 @@ void home_withLocations_addsWeatherMap() throws Exception { } @Test - void home_sortNameAsc_callsCorrectService() throws Exception { + void home_sortNameAsc_passesSortToService() throws Exception { SecUser secUser = secUser(); - when(locationService.getByUserIdSortedByNameAsc(secUser.getId())).thenReturn(List.of()); + when(locationService.getByUserIdSorted(eq(secUser.getId()), eq("nameAsc"), any(Pageable.class))) + .thenReturn(pageOf(List.of(), PageRequest.of(0, 12))); mockMvc.perform(get("/").with(user(secUser)).param("sort", "nameAsc")) .andExpect(status().isOk()); - verify(locationService).getByUserIdSortedByNameAsc(secUser.getId()); + verify(locationService).getByUserIdSorted(eq(secUser.getId()), eq("nameAsc"), any(Pageable.class)); } @Test - void home_sortNameDesc_callsCorrectService() throws Exception { + void home_pageParam_isForwardedToService() throws Exception { SecUser secUser = secUser(); - when(locationService.getByUserIdSortedByNameDesc(secUser.getId())).thenReturn(List.of()); + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), eq(PageRequest.of(2, 12)))) + .thenReturn(pageOf(List.of(), PageRequest.of(2, 12))); - mockMvc.perform(get("/").with(user(secUser)).param("sort", "nameDesc")) - .andExpect(status().isOk()); + mockMvc.perform(get("/").with(user(secUser)).param("page", "2")) + .andExpect(status().isOk()) + .andExpect(model().attribute("currentPage", 2)); - verify(locationService).getByUserIdSortedByNameDesc(secUser.getId()); + verify(locationService).getByUserIdSorted(eq(secUser.getId()), anyString(), eq(PageRequest.of(2, 12))); } @Test - void home_sortFavoriteFirst_callsCorrectService() throws Exception { + void home_negativePageParam_isClampedToZero() throws Exception { SecUser secUser = secUser(); - when(locationService.getByUserIdSortedByFavorite(secUser.getId())).thenReturn(List.of()); + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), eq(PageRequest.of(0, 12)))) + .thenReturn(pageOf(List.of(), PageRequest.of(0, 12))); - mockMvc.perform(get("/").with(user(secUser)).param("sort", "favoriteFirst")) + mockMvc.perform(get("/").with(user(secUser)).param("page", "-5")) .andExpect(status().isOk()); - verify(locationService).getByUserIdSortedByFavorite(secUser.getId()); + verify(locationService).getByUserIdSorted(eq(secUser.getId()), anyString(), eq(PageRequest.of(0, 12))); } @Test - void home_sortFavoritesOnly_callsCorrectService() throws Exception { + void home_paginationAttributes_reflectPageResult() throws Exception { SecUser secUser = secUser(); - when(locationService.getFavoritesByUserId(secUser.getId())).thenReturn(List.of()); + LocationDto location = locationDto(); + WeatherDto weather = weatherDto(); + Page page = new PageImpl<>(List.of(location), PageRequest.of(0, 12), 25); - mockMvc.perform(get("/").with(user(secUser)).param("sort", "favoritesOnly")) - .andExpect(status().isOk()); + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), any(Pageable.class))) + .thenReturn(page); + when(weatherApiService.getWeatherByLocation(location)).thenReturn(weather); - verify(locationService).getFavoritesByUserId(secUser.getId()); + mockMvc.perform(get("/").with(user(secUser))) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPages", page.getTotalPages())) + .andExpect(model().attribute("totalLocations", page.getTotalElements())) + .andExpect(model().attribute("hasNextPage", true)) + .andExpect(model().attribute("hasPreviousPage", false)); } @Test - void home_duplicateLocations_mergesFavoringExisting() throws Exception { + void home_oneLocationFailsToFetch_othersStillRenderAndFailureIsFlagged() throws Exception { SecUser secUser = secUser(); - LocationDto location = locationDto(); + LocationDto okLocation = locationDto().setName("Kyiv"); + LocationDto failingLocation = locationDto().setName("Atlantis"); WeatherDto weather = weatherDto(); - when(locationService.getByUserIdSortedByDate(secUser.getId())) - .thenReturn(List.of(location, location)); - when(weatherApiService.getWeatherByLocation(location)).thenReturn(weather); + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), any(Pageable.class))) + .thenReturn(pageOf(List.of(okLocation, failingLocation), PageRequest.of(0, 12))); + when(weatherApiService.getWeatherByLocation(okLocation)).thenReturn(weather); + when(weatherApiService.getWeatherByLocation(failingLocation)) + .thenThrow(new RuntimeException("provider unavailable")); mockMvc.perform(get("/").with(user(secUser))) .andExpect(status().isOk()) - .andExpect(model().attributeExists("locationWeatherMap")); + .andExpect(model().attributeExists("locationWeatherMap")) + .andExpect(model().attributeExists("errorMessages")); } @Test @@ -165,45 +192,81 @@ void home_unauthenticated_redirectsToSignIn() throws Exception { } @Test - void deleteLocation_redirectsToHome() throws Exception { + void deleteLocation_redirectsToHomePreservingSortAndPage() throws Exception { SecUser secUser = secUser(); UUID locationId = UUID.randomUUID(); mockMvc.perform(delete("/locations/{id}", locationId) .with(user(secUser)) - .with(csrf())) + .with(csrf()) + .param("sort", "nameAsc") + .param("page", "1")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrlPattern("/?sort=*")); + .andExpect(redirectedUrl("/?sort=nameAsc&page=1")); verify(locationService).deleteByIdAndUserId(locationId, secUser.getId()); } @Test - void addToFavorite_redirectsToHome() throws Exception { + void addToFavorite_redirectsToHomePreservingSortAndPage() throws Exception { SecUser secUser = secUser(); UUID locationId = UUID.randomUUID(); mockMvc.perform(post("/locations/{id}/favorite", locationId) .with(user(secUser)) - .with(csrf())) + .with(csrf()) + .param("sort", "favoriteFirst") + .param("page", "3")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrlPattern("/?sort=*")); + .andExpect(redirectedUrl("/?sort=favoriteFirst&page=3")); verify(locationService).addToFavorite(locationId, secUser.getId()); } @Test - void removeFromFavorite_redirectsToHome() throws Exception { + void removeFromFavorite_redirectsToHomePreservingSortAndPage() throws Exception { SecUser secUser = secUser(); UUID locationId = UUID.randomUUID(); mockMvc.perform(delete("/locations/{id}/favorite", locationId) .with(user(secUser)) - .with(csrf())) + .with(csrf()) + .param("sort", "date") + .param("page", "0")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrlPattern("/?sort=*")); + .andExpect(redirectedUrl("/?sort=date&page=0")); verify(locationService).removeFromFavorite(locationId, secUser.getId()); } + @Test + void home_withDuplicateLocations_executesMapMergeFunction() throws Exception { + SecUser secUser = secUser(); + LocationDto location = locationDto(); + WeatherDto weather = weatherDto(); + + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), any(Pageable.class))) + .thenReturn(pageOf(List.of(location, location), PageRequest.of(0, 12))); + when(weatherApiService.getWeatherByLocation(location)).thenReturn(weather); + + mockMvc.perform(get("/").with(user(secUser))) + .andExpect(status().isOk()); + } + + @Test + void home_completionExceptionWithoutCause_usesDirectExceptionMessage() throws Exception { + SecUser secUser = secUser(); + LocationDto location = locationDto(); + + when(locationService.getByUserIdSorted(eq(secUser.getId()), anyString(), any(Pageable.class))) + .thenReturn(pageOf(List.of(location), PageRequest.of(0, 12))); + + when(weatherApiService.getWeatherByLocation(location)) + .thenThrow(new java.util.concurrent.CompletionException("Direct completion error", null)); + + mockMvc.perform(get("/").with(user(secUser))) + .andExpect(status().isOk()) + .andExpect(model().attributeExists("errorMessages")); + } + } From 50e211d4c23f1e0f7153256230208d4482e705a5 Mon Sep 17 00:00:00 2001 From: podlLev Date: Fri, 24 Jul 2026 10:17:41 +0300 Subject: [PATCH 45/62] feat(location): introduce location limit constraint validation and tests --- .../com/weatherviewer/dto/AddLocationDto.java | 2 + .../repository/LocationRepository.java | 16 ++++ .../service/LocationService.java | 21 ++++ .../service/impl/LocationServiceImpl.java | 27 ++++++ .../validation/annotation/LocationLimit.java | 31 ++++++ .../validator/LocationLimitValidator.java | 51 ++++++++++ .../weatherviewer/dto/AddLocationDtoTest.java | 3 +- .../service/impl/LocationServiceImplTest.java | 96 +++++++++++++++++++ .../validator/LocationLimitValidatorTest.java | 82 ++++++++++++++++ 9 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/weatherviewer/validation/annotation/LocationLimit.java create mode 100644 src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java create mode 100644 src/test/java/com/weatherviewer/validation/validator/LocationLimitValidatorTest.java diff --git a/src/main/java/com/weatherviewer/dto/AddLocationDto.java b/src/main/java/com/weatherviewer/dto/AddLocationDto.java index 439e209..665afb5 100644 --- a/src/main/java/com/weatherviewer/dto/AddLocationDto.java +++ b/src/main/java/com/weatherviewer/dto/AddLocationDto.java @@ -1,6 +1,7 @@ package com.weatherviewer.dto; import com.weatherviewer.validation.annotation.Latitude; +import com.weatherviewer.validation.annotation.LocationLimit; import com.weatherviewer.validation.annotation.Longitude; import com.weatherviewer.validation.annotation.UniqueLocation; import io.swagger.v3.oas.annotations.media.Schema; @@ -19,6 +20,7 @@ @ToString @Accessors(chain = true) @UniqueLocation +@LocationLimit @Schema(description = "Payload for creating a saved location") public class AddLocationDto { diff --git a/src/main/java/com/weatherviewer/repository/LocationRepository.java b/src/main/java/com/weatherviewer/repository/LocationRepository.java index 6b6e2dd..137468a 100644 --- a/src/main/java/com/weatherviewer/repository/LocationRepository.java +++ b/src/main/java/com/weatherviewer/repository/LocationRepository.java @@ -1,6 +1,8 @@ package com.weatherviewer.repository; import com.weatherviewer.model.Location; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -21,6 +23,20 @@ public interface LocationRepository extends JpaRepository { /** Returns all locations saved by the given user, in no particular order. */ List findByUserId(UUID userId); + /** + * Returns one page of a user's locations, sorted per the given + * {@link org.springframework.data.domain.Pageable}'s + * {@link org.springframework.data.domain.Sort}. Backs the paginated + * home dashboard. + */ + Page findByUserId(UUID userId, Pageable pageable); + + /** Returns one page of a user's favorited locations, sorted per the given Pageable's Sort. */ + Page findByUserIdAndFavoriteTrue(UUID userId, Pageable pageable); + + /** Counts how many locations the given user has saved, used to enforce the per-user cap. */ + long countByUserId(UUID userId); + /** Returns a user's locations, most recently added first. */ List findByUserIdOrderByCreatedAtDesc(UUID userId); diff --git a/src/main/java/com/weatherviewer/service/LocationService.java b/src/main/java/com/weatherviewer/service/LocationService.java index 65f5257..fa1f220 100644 --- a/src/main/java/com/weatherviewer/service/LocationService.java +++ b/src/main/java/com/weatherviewer/service/LocationService.java @@ -48,6 +48,27 @@ public interface LocationService { /** Returns all locations saved by the given user, unsorted. */ List getByUserId(UUID userId); + /** + * Counts how many locations the given user currently has saved. + * Backs {@link com.weatherviewer.validation.annotation.LocationLimit}, + * which rejects new locations once a user hits the configured cap + * ({@code location.max-per-user}) — without this, a single user could + * save an unbounded number of locations and force the dashboard to + * fan out an unbounded number of weather calls on every page load. + */ + long countByUserId(UUID userId); + + /** + * Returns one page of a user's saved locations in the given dashboard + * sort order ({@code date}, {@code nameAsc}, {@code nameDesc}, + * {@code favoriteFirst}, or {@code favoritesOnly} — unrecognized/blank + * values fall back to {@code date}). Backs the paginated home + * dashboard, so a user with many saved locations only loads (and + * fetches weather for) one page's worth per request instead of + * everything at once. + */ + Page getByUserIdSorted(UUID userId, String sort, Pageable pageable); + /** Looks up a user's saved location by its exact coordinates. */ LocationDto getByCoordinatesAndUserId(Double latitude, Double longitude, UUID userId); diff --git a/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java index 2bac840..5d01a03 100644 --- a/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java @@ -9,7 +9,9 @@ import com.weatherviewer.service.LocationService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,6 +70,31 @@ public List getByUserId(UUID userId) { return locationMapper.toDtoList(locations); } + @Override + public long countByUserId(UUID userId) { + return locationRepository.countByUserId(userId); + } + + @Override + public Page getByUserIdSorted(UUID userId, String sort, Pageable pageable) { + String normalizedSort = sort == null ? "date" : sort.toLowerCase(); + + if ("favoritesonly".equals(normalizedSort)) { + Pageable request = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Sort.Direction.DESC, "createdAt")); + return locationRepository.findByUserIdAndFavoriteTrue(userId, request).map(locationMapper::toDto); + } + + Sort sortOrder = switch (normalizedSort) { + case "nameasc" -> Sort.by(Sort.Direction.ASC, "name"); + case "namedesc" -> Sort.by(Sort.Direction.DESC, "name"); + case "favoritefirst" -> Sort.by(Sort.Order.desc("favorite"), Sort.Order.desc("createdAt")); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + Pageable request = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortOrder); + return locationRepository.findByUserId(userId, request).map(locationMapper::toDto); + } + @Override public LocationDto getByCoordinatesAndUserId(Double latitude, Double longitude, UUID userId) { Location location = locationRepository.findByLatitudeAndLongitudeAndUserId(latitude, longitude, userId); diff --git a/src/main/java/com/weatherviewer/validation/annotation/LocationLimit.java b/src/main/java/com/weatherviewer/validation/annotation/LocationLimit.java new file mode 100644 index 0000000..90221f5 --- /dev/null +++ b/src/main/java/com/weatherviewer/validation/annotation/LocationLimit.java @@ -0,0 +1,31 @@ +package com.weatherviewer.validation.annotation; + +import com.weatherviewer.validation.validator.LocationLimitValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * Class-level constraint on {@link com.weatherviewer.dto.AddLocationDto} + * rejecting a new location once the owning user has reached the + * configured per-user cap ({@code location.max-per-user}, default 100). + *

      + * Without this, a single account (or a script hitting + * {@code /api/v1/locations/my} directly) could save an unbounded number + * of locations, and every dashboard load fans out one weather call per + * saved location. See {@link LocationLimitValidator}. + */ +@Documented +@Constraint(validatedBy = LocationLimitValidator.class) +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface LocationLimit { + + String message() default "Maximum number of saved locations reached"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java b/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java new file mode 100644 index 0000000..d563157 --- /dev/null +++ b/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java @@ -0,0 +1,51 @@ +package com.weatherviewer.validation.validator; + +import com.weatherviewer.dto.AddLocationDto; +import com.weatherviewer.service.LocationService; +import com.weatherviewer.validation.annotation.LocationLimit; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Backs {@link LocationLimit}. Counts how many locations the owning user + * already has saved and rejects the new one if that count is already at + * or above {@code location.max-per-user}. + *

      + * Skips the check (returns valid) when {@code userId} is missing, since + * that case is already reported by the DTO's own required-field + * validation and shouldn't also surface a misleading "limit reached" + * message. + */ +@Component +@RequiredArgsConstructor +public class LocationLimitValidator implements ConstraintValidator { + + private final LocationService locationService; + + @Value("${location.max-per-user:100}") + private int maxLocationsPerUser; + + @Override + public boolean isValid(AddLocationDto addLocationDto, ConstraintValidatorContext context) { + if (addLocationDto == null || addLocationDto.getUserId() == null) { + return true; + } + + long currentCount = locationService.countByUserId(addLocationDto.getUserId()); + + if (currentCount >= maxLocationsPerUser) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate( + "You've reached the maximum of " + maxLocationsPerUser + " saved locations") + .addPropertyNode("location") + .addConstraintViolation(); + return false; + } + + return true; + } + +} diff --git a/src/test/java/com/weatherviewer/dto/AddLocationDtoTest.java b/src/test/java/com/weatherviewer/dto/AddLocationDtoTest.java index e1d9ba0..78022e4 100644 --- a/src/test/java/com/weatherviewer/dto/AddLocationDtoTest.java +++ b/src/test/java/com/weatherviewer/dto/AddLocationDtoTest.java @@ -1,5 +1,6 @@ package com.weatherviewer.dto; +import com.weatherviewer.validation.validator.LocationLimitValidator; import com.weatherviewer.validation.validator.UniqueLocationValidator; import jakarta.validation.*; import org.junit.jupiter.api.BeforeEach; @@ -15,7 +16,7 @@ class AddLocationDtoTest { @BeforeEach void setUp() { - validator = skipValidator(UniqueLocationValidator.class); + validator = skipValidator(UniqueLocationValidator.class, LocationLimitValidator.class); } private AddLocationDto validDto() { diff --git a/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java index d8dcad5..25ef368 100644 --- a/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/LocationServiceImplTest.java @@ -158,6 +158,102 @@ void getByUserId_returnsMappedList() { assertThat(service.getByUserId(userId)).isEqualTo(dtos); } + @Test + void countByUserId_returnsRepositoryCount() { + UUID userId = UUID.randomUUID(); + when(locationRepository.countByUserId(userId)).thenReturn(3L); + + assertThat(service.countByUserId(userId)).isEqualTo(3L); + } + + @Test + void getByUserIdSorted_defaultSort_queriesByCreatedAtDesc() { + UUID userId = UUID.randomUUID(); + Location location = new Location(); + LocationDto dto = new LocationDto(); + Pageable requested = PageRequest.of(0, 12); + org.springframework.data.domain.Page repoPage = new PageImpl<>(List.of(location), requested, 1); + + when(locationRepository.findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class))) + .thenReturn(repoPage); + when(locationMapper.toDto(location)).thenReturn(dto); + + org.springframework.data.domain.Page result = service.getByUserIdSorted(userId, "date", requested); + + assertThat(result.getContent()).containsExactly(dto); + } + + @Test + void getByUserIdSorted_nullSort_fallsBackToDate() { + UUID userId = UUID.randomUUID(); + Pageable requested = PageRequest.of(0, 12); + + when(locationRepository.findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), requested, 0)); + + org.springframework.data.domain.Page result = service.getByUserIdSorted(userId, null, requested); + + assertThat(result.getTotalElements()).isZero(); + } + + @Test + void getByUserIdSorted_nameAsc_queriesByUserId() { + UUID userId = UUID.randomUUID(); + Pageable requested = PageRequest.of(0, 12); + + when(locationRepository.findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), requested, 0)); + + service.getByUserIdSorted(userId, "nameAsc", requested); + + verify(locationRepository).findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class)); + } + + @Test + void getByUserIdSorted_nameDesc_queriesByUserId() { + UUID userId = UUID.randomUUID(); + Pageable requested = PageRequest.of(0, 12); + + when(locationRepository.findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), requested, 0)); + + service.getByUserIdSorted(userId, "nameDesc", requested); + + verify(locationRepository).findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class)); + } + + @Test + void getByUserIdSorted_favoriteFirst_queriesByUserId() { + UUID userId = UUID.randomUUID(); + Pageable requested = PageRequest.of(0, 12); + + when(locationRepository.findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), requested, 0)); + + service.getByUserIdSorted(userId, "favoriteFirst", requested); + + verify(locationRepository).findByUserId(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class)); + } + + @Test + void getByUserIdSorted_favoritesOnly_queriesFavoriteTrue() { + UUID userId = UUID.randomUUID(); + Location location = new Location(); + LocationDto dto = new LocationDto(); + Pageable requested = PageRequest.of(0, 12); + org.springframework.data.domain.Page repoPage = new PageImpl<>(List.of(location), requested, 1); + + when(locationRepository.findByUserIdAndFavoriteTrue(org.mockito.ArgumentMatchers.eq(userId), any(Pageable.class))) + .thenReturn(repoPage); + when(locationMapper.toDto(location)).thenReturn(dto); + + org.springframework.data.domain.Page result = + service.getByUserIdSorted(userId, "favoritesOnly", requested); + + assertThat(result.getContent()).containsExactly(dto); + verify(locationRepository, never()).findByUserId(any(UUID.class), any(Pageable.class)); + } + @Test void getByCoordinatesAndUserId_returnsMappedDto() { UUID userId = UUID.randomUUID(); diff --git a/src/test/java/com/weatherviewer/validation/validator/LocationLimitValidatorTest.java b/src/test/java/com/weatherviewer/validation/validator/LocationLimitValidatorTest.java new file mode 100644 index 0000000..92c2259 --- /dev/null +++ b/src/test/java/com/weatherviewer/validation/validator/LocationLimitValidatorTest.java @@ -0,0 +1,82 @@ +package com.weatherviewer.validation.validator; + +import com.weatherviewer.dto.AddLocationDto; +import com.weatherviewer.service.LocationService; +import jakarta.validation.ConstraintValidatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LocationLimitValidatorTest { + + private static final int MAX_LOCATIONS = 100; + + @Mock + private LocationService locationService; + + @InjectMocks + private LocationLimitValidator validator; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(validator, "maxLocationsPerUser", MAX_LOCATIONS); + } + + private ConstraintValidatorContext mockContext() { + return mock(ConstraintValidatorContext.class, RETURNS_DEEP_STUBS); + } + + private AddLocationDto validDto() { + return new AddLocationDto() + .setName("Kyiv") + .setLatitude(50.45) + .setLongitude(30.52) + .setUserId(UUID.randomUUID()); + } + + @Test + void null_dto_returnsTrue() { + assertThat(validator.isValid(null, mockContext())).isTrue(); + } + + @Test + void null_userId_returnsTrue() { + AddLocationDto dto = validDto().setUserId(null); + assertThat(validator.isValid(dto, mockContext())).isTrue(); + } + + @Test + void underLimit_returnsTrue() { + AddLocationDto dto = validDto(); + when(locationService.countByUserId(dto.getUserId())).thenReturn((long) (MAX_LOCATIONS - 1)); + + assertThat(validator.isValid(dto, mockContext())).isTrue(); + } + + @Test + void atLimit_returnsFalse() { + AddLocationDto dto = validDto(); + when(locationService.countByUserId(dto.getUserId())).thenReturn((long) MAX_LOCATIONS); + + assertThat(validator.isValid(dto, mockContext())).isFalse(); + } + + @Test + void overLimit_returnsFalse() { + AddLocationDto dto = validDto(); + when(locationService.countByUserId(dto.getUserId())).thenReturn((long) (MAX_LOCATIONS + 1)); + + assertThat(validator.isValid(dto, mockContext())).isFalse(); + } + +} From 33cc22af667f7804627ec00c2497d2bfc57429ff Mon Sep 17 00:00:00 2001 From: podlLev Date: Sat, 25 Jul 2026 14:56:17 +0300 Subject: [PATCH 46/62] docs(readme): document features shipped since v1.1.0 Add remember-me, email verification/password reset, account lockout, unit preference, dark mode, admin pagination, weather API resilience, and rate-limit hardening to the feature list. Add Observability, Security, and CI/CD sections; update env var docs and project structure to match. --- README.md | 161 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 118 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 1325d2f..17951d2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ WeatherViewer logo

      WeatherViewer

      - +

      A Spring Boot web app for tracking weather and forecasts across your favorite locations.

      Java 17 @@ -10,15 +10,19 @@ PostgreSQL 17 Redis 7 Docker ready + CI GitHub Actions

      - +

      Overview · Features · Tech Stack · Getting Started · API Docs · + Observability · + Security · Testing · + CI/CD · Structure

      @@ -26,37 +30,49 @@ ## Overview -WeatherViewer is a personal weather dashboard for tracking the places you care about. Sign up, search for any city, and save it — your dashboard then shows current conditions for every saved location at a glance, with hourly and daily forecasts just a click away. Mark your most-checked spots as favorites to keep them front and center. Live weather and location data come from the OpenWeatherMap API, and Redis caching keeps everything running smoothly behind the scenes. +WeatherViewer is a personal weather dashboard for tracking the places you care about. Sign up, verify your email, search for any city, and save it — your dashboard then shows current conditions for every saved location at a glance, with hourly and daily forecasts just a click away, in the units you prefer. Mark your most-checked spots as favorites to keep them front and center. Live weather and location data come from the OpenWeatherMap API, calls to which are cached and protected by retries and a circuit breaker, so a flaky upstream doesn't take your dashboard down with it. ## Features - **Authentication** — sign-up and sign-in with Spring Security, BCrypt password hashing, and role-based access (`USER` / `ADMIN`) -- **Location search** — look up cities by name via the OpenWeatherMap Geocoding API and save them to your account -- **Dashboard** — view current weather for all saved locations, sortable by date added, name, or favorite status +- **Email verification & password reset** — new accounts are confirmed via an emailed verification link, and a self-service "forgot password" flow lets users reset their password with an expiring token +- **Remember-me** — persistent-token "remember me" on sign-in, so sessions survive a browser restart +- **Account lockout** — an account is temporarily locked after repeated failed sign-in attempts, protecting it against brute-force guessing +- **Location search** — look up cities by name via the OpenWeatherMap Geocoding API and save them to your account, up to a configurable per-user limit +- **Dashboard** — view current weather for all saved locations, sortable by date added, name, or favorite status; if one location's weather fails to load, the rest of the dashboard still renders - **Favorites** — mark/unmark locations as favorites and filter the dashboard to show only those - **Forecasts** — hourly and daily forecast views for any saved location -- **User profile** — update account details from a dedicated profile page -- **REST API** — JSON endpoints for users, locations, and weather data, documented with OpenAPI/Swagger +- **Unit preference** — choose metric or imperial units on your profile; all displayed weather and forecast values are converted accordingly +- **Dark mode** — a light/dark theme toggle, remembered across pages +- **User profile** — update account details and preferences from a dedicated profile page +- **REST API** — JSON endpoints for users, locations, and weather data, documented with OpenAPI/Swagger, with pagination on the admin list endpoints - **Caching** — weather, forecast, and geocoding responses are cached (Redis in production, in-memory for local/dev) to reduce external API calls +- **Resilience** — retries with exponential backoff and a circuit breaker (Resilience4j) around every call to the OpenWeatherMap API +- **Rate limiting** — per-route request limits on sign-in, sign-up, and the REST API, safe against `X-Forwarded-For` spoofing and skipping static assets - **Database migrations** — schema is version-controlled and applied automatically via Liquibase +- **Observability** — Actuator health/liveness/readiness probes and Prometheus metrics exposed on a separate management port, plus request correlation IDs in logs +- **Hardened by default** — strict Content-Security-Policy, secure/`SameSite=Strict` session cookies, a single concurrent session per user, and a non-root container image ## Tech Stack - -| Layer | Technology | -|:-----------------|:-----------------------------------------------------------------------------------------| -| Language | Java 17 | -| Framework | Spring Boot 3.5 (Web, Security, Data JPA, Validation, Cache) | -| Templating | Thymeleaf | -| Database | PostgreSQL | -| Migrations | Liquibase | -| Caching | Redis (Spring Cache) | -| Mapping | MapStruct | -| API docs | springdoc-openapi (Swagger UI & Scalar) | -| Build tool | Maven | -| Testing | JUnit, Spring Boot Test, Spring Security Test, H2 (in-memory test DB), JaCoCo (coverage) | -| Containerization | Docker, Docker Compose | -| External API | [OpenWeatherMap](https://openweathermap.org/api) (current weather, forecast, geocoding) | +| Layer | Technology | +|:-----------------|:-------------------------------------------------------------------------------------------| +| Language | Java 17 | +| Framework | Spring Boot 3.5 (Web, Security, Data JPA, Validation, Cache, Mail, Actuator) | +| Templating | Thymeleaf | +| Database | PostgreSQL | +| Migrations | Liquibase | +| Caching | Redis (Spring Cache) | +| Resilience | Resilience4j (retry + circuit breaker) | +| Mapping | MapStruct | +| Mail | Spring Mail (SMTP) — email verification and password reset | +| API docs | springdoc-openapi (Swagger UI & Scalar) | +| Observability | Spring Boot Actuator (health/liveness/readiness), Micrometer + Prometheus, correlation IDs | +| Build tool | Maven | +| Testing | JUnit, Spring Boot Test, Spring Security Test, H2 (in-memory test DB), JaCoCo (coverage) | +| Containerization | Docker, Docker Compose (non-root runtime image) | +| CI/CD | GitHub Actions (build/test, coverage, Docker Hub image push) | +| External API | [OpenWeatherMap](https://openweathermap.org/api) (current weather, forecast, geocoding) | ## Prerequisites @@ -64,6 +80,7 @@ WeatherViewer is a personal weather dashboard for tracking the places you care a - Maven - Docker and Docker Compose (recommended — handles Postgres and Redis for you) - An [OpenWeatherMap API key](https://openweathermap.org/api) (free tier is sufficient) +- An SMTP server/account for sending verification and password-reset emails (a local dev tool like [MailHog](https://github.com/mailhog/MailHog) or [Mailpit](https://github.com/axllent/mailpit) works well for local development) ## Getting Started @@ -92,10 +109,36 @@ WEATHER_API_KEY=your_openweathermap_api_key SPRING_DATA_REDIS_HOST=localhost SPRING_DATA_REDIS_PORT=6379 SPRING_CACHE_TYPE=redis + +# Remember-me (any long random string) +REMEMBER_ME_KEY=change-me-to-a-random-secret + +# Mail — used for account verification and password-reset emails +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_SMTP_AUTH=false +MAIL_SMTP_STARTTLS=false +MAIL_FROM=no-reply@weatherviewer.local + +# Base URL used to build links inside verification/reset emails +APP_BASE_URL=http://localhost:8080 ``` > **Never commit your real `.env` file or API key.** Add `.env` to `.gitignore` and rotate any key that has previously been pushed to a public repo. +Other useful settings (all have sensible defaults — see `application.properties`): + +| Variable | Default | Purpose | +|:-----------------------------------|:----------|:-------------------------------------------------------------| +| `LOCATION_MAX_PER_USER` | `100` | Max saved locations per user | +| `ACCOUNT_LOCKOUT_MAX_ATTEMPTS` | `5` | Failed sign-ins before an account is locked | +| `ACCOUNT_LOCKOUT_DURATION_MINUTES` | `15` | How long a locked account stays locked | +| `RATE_LIMIT_ENABLED` | `true` | Toggle the rate-limiting filter | +| `TRUSTED_PROXIES` | *(empty)* | CIDRs/IPs allowed to set `X-Forwarded-For` for rate limiting | +| `MANAGEMENT_SERVER_PORT` | `8081` | Port for Actuator health/metrics endpoints | + ### 3. Start dependencies (Postgres + Redis) For local development: @@ -111,7 +154,7 @@ This starts Postgres on port `5433` and Redis on port `6379`, matching the `.env Using the Maven wrapper: ```bash -mvn spring-boot:run +./mvnw spring-boot:run ``` The app will be available at **http://localhost:8080**. @@ -124,7 +167,7 @@ To run the full stack (app + Postgres + Redis) in containers: docker compose up -d ``` -This pulls the app image, applies Liquibase migrations on startup, and wires up all three services with health checks. +This pulls the app image, applies Liquibase migrations on startup, and wires up all three services with health checks. The app container runs as a non-root user and exposes both the app port (`8080`) and the management port (`8081`). ## API Documentation @@ -136,38 +179,70 @@ The project provides interactive API documentation using both OpenAPI (Swagger U | **Swagger UI** | `/swagger-ui/index.html` | Classic Swagger interface to explore and test endpoints. | | **OpenAPI Spec** | `/v3/api-docs` | Raw OpenAPI 3.0 specification in JSON format. | -REST endpoints are namespaced under `/api/v1/` (e.g. `/api/v1/weather/city`, `/api/v1/locations/my`, `/api/v1/users`). +REST endpoints are namespaced under `/api/v1/` (e.g. `/api/v1/weather/city`, `/api/v1/locations/my`, `/api/v1/users`). The admin-facing list endpoints (`GET /api/v1/users`, `GET /api/v1/locations`) are paginated, with a maximum page size of 100. + +## Observability + +Actuator runs on a separate management port so it can be kept off the public network: + +| Endpoint | Purpose | +|:--------------------------------------------|:-------------------------------------------------| +| `http://localhost:8081/actuator/health` | Liveness/readiness health check (used by Docker) | +| `http://localhost:8081/actuator/prometheus` | Prometheus-formatted metrics | +| `http://localhost:8081/actuator/info` | Build/app info | + +Every log line is tagged with a request correlation ID, and HTTP request latency is exported as a histogram for easy percentile/SLO tracking. + +## Security + +- Passwords are hashed with BCrypt; sign-in is protected by per-account lockout after repeated failed attempts +- Session cookies are `Secure` and `SameSite=Strict`, and each user is limited to one concurrent session +- A strict Content-Security-Policy header is set on every response +- Rate limiting on sign-in, sign-up, and the REST API is resistant to `X-Forwarded-For` spoofing (only trusted proxies listed in `TRUSTED_PROXIES` are honored) and skips static assets +- The OpenWeatherMap API key is never written to logs +- The production container image runs as a non-root user ## Running Tests ```bash -mvn test +./mvnw test ``` -Tests run against an in-memory H2 database, so no external services are required. The suite includes unit tests, MVC/REST controller tests, repository tests, and full integration tests for auth, search, profile, and weather flows. JaCoCo generates a coverage report at `target/site/jacoco/index.html` after running tests. +Tests run against an in-memory H2 database, so no external services are required. The suite includes unit tests, MVC/REST controller tests, repository tests, and full integration tests for auth (including verification, password reset, and remember-me), search, profile, and weather flows. JaCoCo generates a coverage report at `target/site/jacoco/index.html` after running tests. + +## CI/CD + +Every push to `main` and every pull request into `main`/`dev` runs through GitHub Actions: + +1. **Build & Test** — compiles the project and runs the full test suite against H2, publishing a JUnit test report and a JaCoCo coverage report as workflow artifacts. +2. **Docker build & push** — on pushes to `main`, builds the application image and pushes it to Docker Hub as `podllev/weather-viewer`. + +See `.github/workflows/ci.yml` for the full pipeline. ## Project Structure ``` src/main/java/com/weatherviewer/ -├── config/ # Security and app-level configuration -├── controller/ # Thymeleaf (MVC) controllers — sign-in/up, home, search, profile, forecast -├── rest/ # REST API controllers (/api/v1/...) -├── dto/ # Data transfer objects -├── model/ # JPA entities (User, Location) and enums -├── repository/ # Spring Data JPA repositories -├── service/ # Business logic interfaces + implementations -│ └── integration/ # OpenWeatherMap API client and caching layer -├── security/ # Spring Security principal, success/failure handlers -├── validation/ # Custom annotations and validators (lat/lon, password, uniqueness) -├── mapper/ # MapStruct entity↔DTO mappers -└── exception/ # Custom exceptions and global exception handlers +├── config/ # Security and app-level configuration +├── controller/ # Thymeleaf (MVC) controllers — sign-in/up, home, search, profile, forecast, password reset +├── rest/ # REST API controllers (/api/v1/...) +├── dto/ # Data transfer objects +├── model/ # JPA entities (User, Location, VerificationToken) and enums +├── ratelimit/ # Rate-limiting filter and Redis-backed fixed-window limiter +├── repository/ # Spring Data JPA repositories +├── service/ # Business logic interfaces + implementations (mail, verification, weather, users, locations) +│ └── integration/ # OpenWeatherMap API client, retry/circuit-breaker config, and caching layer +├── security/ # Spring Security principal, success/failure/lockout handlers +├── validation/ # Custom annotations and validators (lat/lon, password, uniqueness, location limit) +├── mapper/ # MapStruct entity↔DTO mappers +└── exception/ # Custom exceptions and global exception handlers src/main/resources/ -├── templates/ # Thymeleaf views -├── static/ # CSS, JS, images -└── liquibase/ # Database changelogs +├── templates/ # Thymeleaf views +├── static/ # CSS, JS, images +└── liquibase/ # Database changelogs ``` + ## License - + MIT — see [LICENSE](LICENSE) for details. From 43271d8c6aab7470b374dcf8a87189245d98622f Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:36:46 +0300 Subject: [PATCH 47/62] feat(mail): add VerificationEmailRequestedEvent and PasswordResetEmailRequestedEvent --- .../event/PasswordResetEmailRequestedEvent.java | 12 ++++++++++++ .../event/VerificationEmailRequestedEvent.java | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java create mode 100644 src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java diff --git a/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java b/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java new file mode 100644 index 0000000..1f07d48 --- /dev/null +++ b/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java @@ -0,0 +1,12 @@ +package com.weatherviewer.event; + +/** + * Published by {@link com.weatherviewer.service.impl.VerificationServiceImpl} + * once a password-reset token has been persisted. Consumed by + * {@link com.weatherviewer.service.impl.MailEventListener}, which sends the + * actual email asynchronously and only after the surrounding transaction + * commits — so a rolled-back reset request never results in an email + * pointing at a token that was never saved. + */ +public record PasswordResetEmailRequestedEvent(String email, String firstName, String resetLink) { +} diff --git a/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java b/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java new file mode 100644 index 0000000..d72edf3 --- /dev/null +++ b/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java @@ -0,0 +1,12 @@ +package com.weatherviewer.event; + +/** + * Published by {@link com.weatherviewer.service.impl.VerificationServiceImpl} + * once a verification token has been persisted. Consumed by + * {@link com.weatherviewer.service.impl.MailEventListener}, which sends the + * actual email asynchronously and only after the surrounding transaction + * commits — so a rolled-back sign-up never results in an email pointing at + * a token that was never saved. + */ +public record VerificationEmailRequestedEvent(String email, String firstName, String verificationLink) { +} From 8eec66224e6c9290d9b1841df85e648ef5011ba1 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:37:25 +0300 Subject: [PATCH 48/62] feat(mail): dispatch mail via events instead of calling MailService directly --- .../service/impl/VerificationServiceImpl.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java index fc60ac5..cff158b 100644 --- a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java @@ -1,5 +1,7 @@ package com.weatherviewer.service.impl; +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; import com.weatherviewer.exception.InvalidTokenException; import com.weatherviewer.model.User; import com.weatherviewer.model.VerificationToken; @@ -7,10 +9,10 @@ import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.repository.VerificationTokenRepository; -import com.weatherviewer.service.MailService; import com.weatherviewer.service.VerificationService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +30,13 @@ * emailed link. Issuing a new token for a given user/purpose first * invalidates any earlier unused ones of that same type, so only the most * recently emailed link ever works. + *

      + * The actual email is not sent from here. Instead, an + * {@code *EmailRequestedEvent} is published, which + * {@link MailEventListener} picks up asynchronously and only after this + * method's transaction has committed — so a rollback (or a slow/unreachable + * mail server) can never desync the token that was persisted from the link + * that was emailed. */ @Service @Slf4j @@ -37,7 +46,7 @@ public class VerificationServiceImpl implements VerificationService { private final UserRepository userRepository; private final VerificationTokenRepository tokenRepository; - private final MailService mailService; + private final ApplicationEventPublisher eventPublisher; private final PasswordEncoder passwordEncoder; private final String baseUrl; private final long verificationTtlHours; @@ -45,14 +54,14 @@ public class VerificationServiceImpl implements VerificationService { public VerificationServiceImpl(UserRepository userRepository, VerificationTokenRepository tokenRepository, - MailService mailService, + ApplicationEventPublisher eventPublisher, PasswordEncoder passwordEncoder, @Value("${app.base-url:http://localhost:8080}") String baseUrl, @Value("${app.verification.token-ttl-hours:24}") long verificationTtlHours, @Value("${app.password-reset.token-ttl-hours:1}") long passwordResetTtlHours) { this.userRepository = userRepository; this.tokenRepository = tokenRepository; - this.mailService = mailService; + this.eventPublisher = eventPublisher; this.passwordEncoder = passwordEncoder; this.baseUrl = baseUrl; this.verificationTtlHours = verificationTtlHours; @@ -71,7 +80,7 @@ public void sendVerificationEmail(User user) { .queryParam("token", rawToken) .toUriString(); - mailService.sendVerificationEmail(user.getEmail(), user.getFirstName(), link); + eventPublisher.publishEvent(new VerificationEmailRequestedEvent(user.getEmail(), user.getFirstName(), link)); } @Override @@ -98,7 +107,7 @@ public void requestPasswordReset(String email) { .queryParam("token", rawToken) .toUriString(); - mailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), link); + eventPublisher.publishEvent(new PasswordResetEmailRequestedEvent(user.getEmail(), user.getFirstName(), link)); }, () -> log.info("Password reset requested for unregistered email={}", email)); } From 191cc60d05ff4e7a8b2907021262874669db9fb5 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:38:29 +0300 Subject: [PATCH 49/62] feat(mail): add MailEventListener to send mail async after transaction commit --- .../service/impl/MailEventListener.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/main/java/com/weatherviewer/service/impl/MailEventListener.java diff --git a/src/main/java/com/weatherviewer/service/impl/MailEventListener.java b/src/main/java/com/weatherviewer/service/impl/MailEventListener.java new file mode 100644 index 0000000..ee8403b --- /dev/null +++ b/src/main/java/com/weatherviewer/service/impl/MailEventListener.java @@ -0,0 +1,57 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; +import com.weatherviewer.service.MailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Bridges {@code *EmailRequestedEvent}s raised by + * {@link VerificationServiceImpl} to actual sends via {@link MailService}. + *

      + * Two things happen here that matter for reliability: + *

        + *
      • {@link TransactionalEventListener} with + * {@link TransactionPhase#AFTER_COMMIT} defers the send until the + * transaction that created the token has actually committed. If that + * transaction rolls back for any reason, the event is discarded and + * no email goes out — previously, the mail call sat inside the same + * {@code @Transactional} method as the token write, so a caller could + * (in principle) receive a link for a token that was never persisted.
      • + *
      • {@link Async} runs the send on a dedicated pool + * ({@code mailTaskExecutor}, see {@code AppConfig}) rather than the + * request thread, so a slow or unreachable SMTP server can no longer + * add latency to sign-up, email verification, or password-reset + * requests.
      • + *
      + * {@link MailService} itself still retries transient SMTP failures and + * never throws, so a failure here is logged by it directly and does not + * propagate any further. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class MailEventListener { + + private final MailService mailService; + + @Async("mailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onVerificationEmailRequested(VerificationEmailRequestedEvent event) { + log.debug("Dispatching verification email to {}", event.email()); + mailService.sendVerificationEmail(event.email(), event.firstName(), event.verificationLink()); + } + + @Async("mailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPasswordResetEmailRequested(PasswordResetEmailRequestedEvent event) { + log.debug("Dispatching password reset email to {}", event.email()); + mailService.sendPasswordResetEmail(event.email(), event.firstName(), event.resetLink()); + } + +} From a2868365d142fed3d16f697a9064020d843be652 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:38:49 +0300 Subject: [PATCH 50/62] config: add mailTaskExecutor bean and enable @Async support --- .../WeatherViewerApplication.java | 7 ++++++- .../com/weatherviewer/config/AppConfig.java | 18 ++++++++++++++++++ .../com/weatherviewer/mapper/UserMapper.java | 2 ++ src/main/resources/application.properties | 8 ++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/weatherviewer/WeatherViewerApplication.java b/src/main/java/com/weatherviewer/WeatherViewerApplication.java index aff32cc..79f5158 100644 --- a/src/main/java/com/weatherviewer/WeatherViewerApplication.java +++ b/src/main/java/com/weatherviewer/WeatherViewerApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableAsync; import java.util.TimeZone; @@ -12,10 +13,14 @@ * Bootstraps the application context and enables Spring's caching * abstraction ({@link EnableCaching}), which backs the weather/forecast/ * geocoding response caching in - * {@link com.weatherviewer.service.integration.WeatherApiCache}. + * {@link com.weatherviewer.service.integration.WeatherApiCache}, and + * Spring's {@code @Async} support ({@link EnableAsync}), which backs + * asynchronous mail dispatch in + * {@link com.weatherviewer.service.impl.MailEventListener}. */ @SpringBootApplication @EnableCaching +@EnableAsync public class WeatherViewerApplication { /** diff --git a/src/main/java/com/weatherviewer/config/AppConfig.java b/src/main/java/com/weatherviewer/config/AppConfig.java index 33df369..1cf5621 100644 --- a/src/main/java/com/weatherviewer/config/AppConfig.java +++ b/src/main/java/com/weatherviewer/config/AppConfig.java @@ -72,4 +72,22 @@ public ExecutorService weatherFetchExecutor( return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("weather-fetch-")); } + /** + * Dedicated, bounded thread pool used by + * {@link com.weatherviewer.service.impl.MailEventListener} to send + * verification/password-reset emails off the request thread. + *

      + * Kept separate from {@link #weatherFetchExecutor} for the same reason + * that one is kept separate from the JVM-wide common pool: a burst of + * sign-ups or reset requests (each possibly retried a few times against + * a slow SMTP server) shouldn't be able to starve weather-dashboard + * fetches, or vice versa. Sized via {@code mail.async.pool-size} + * (default 5) and shut down automatically on context close. + */ + @Bean(name = "mailTaskExecutor", destroyMethod = "shutdown") + public ExecutorService mailTaskExecutor( + @Value("${mail.async.pool-size:5}") int poolSize) { + return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("mail-send-")); + } + } diff --git a/src/main/java/com/weatherviewer/mapper/UserMapper.java b/src/main/java/com/weatherviewer/mapper/UserMapper.java index 31bb030..4fa4781 100644 --- a/src/main/java/com/weatherviewer/mapper/UserMapper.java +++ b/src/main/java/com/weatherviewer/mapper/UserMapper.java @@ -28,6 +28,8 @@ public interface UserMapper { @Mapping(target = "createdAt", ignore = true) @Mapping(target = "units", ignore = true) @Mapping(target = "locations", ignore = true) + @Mapping(target = "lockedUntil", ignore = true) + @Mapping(target = "failedLoginAttempts", ignore = true) User fromRecord(CreateUserDto createUserDto); /** diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4cc8610..6d8610d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -82,6 +82,12 @@ resilience4j.circuitbreaker.instances.weatherApi.wait-duration-in-open-state=30s resilience4j.circuitbreaker.instances.weatherApi.permitted-number-of-calls-in-half-open-state=5 resilience4j.circuitbreaker.instances.weatherApi.automatic-transition-from-open-to-half-open-enabled=true +# --- Resilience (retry around SMTP sends; see MailServiceImpl) --- +resilience4j.retry.instances.mail.max-attempts=3 +resilience4j.retry.instances.mail.wait-duration=500ms +resilience4j.retry.instances.mail.enable-exponential-backoff=true +resilience4j.retry.instances.mail.exponential-backoff-multiplier=2 + # --- Redis / caching --- spring.data.redis.host=${SPRING_DATA_REDIS_HOST:localhost} spring.data.redis.port=${SPRING_DATA_REDIS_PORT:6379} @@ -100,6 +106,8 @@ spring.mail.properties.mail.smtp.timeout=5000 spring.mail.properties.mail.smtp.writetimeout=5000 app.mail.from=${MAIL_FROM:no-reply@weatherviewer.local} +mail.async.pool-size=5 + # --- Links embedded in emailed verification / reset tokens --- app.base-url=${APP_BASE_URL:http://localhost:8080} app.verification.token-ttl-hours=24 From c3f0f8608fdaaaf10e118e94224743d50177fdfc Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:39:08 +0300 Subject: [PATCH 51/62] feat(mail): retry transient SMTP failures via resilience4j --- .../weatherviewer/service/MailService.java | 12 ++++- .../service/impl/MailServiceImpl.java | 46 ++++++++++++++----- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/MailService.java b/src/main/java/com/weatherviewer/service/MailService.java index 280e0cb..f168916 100644 --- a/src/main/java/com/weatherviewer/service/MailService.java +++ b/src/main/java/com/weatherviewer/service/MailService.java @@ -2,8 +2,16 @@ /** * Outbound transactional email. Implementations must never let a mail - * provider outage break the calling request (sign-up, password reset) — - * failures are logged and swallowed rather than propagated. + * provider outage break the calling request — failures are retried a few + * times, then logged and swallowed rather than propagated. + *

      + * In practice these methods are only ever invoked by + * {@link com.weatherviewer.service.impl.MailEventListener}, asynchronously + * and after the transaction that created the underlying token has + * committed (see {@link com.weatherviewer.event.VerificationEmailRequestedEvent} + * / {@link com.weatherviewer.event.PasswordResetEmailRequestedEvent}), so a + * slow or unreachable mail server never adds latency to — or breaks — the + * sign-up, verification, or password-reset request itself. */ public interface MailService { diff --git a/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java index be96b35..47bc009 100644 --- a/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java @@ -1,6 +1,8 @@ package com.weatherviewer.service.impl; import com.weatherviewer.service.MailService; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.MailException; @@ -12,21 +14,34 @@ * {@link MailService} implementation backed by {@link JavaMailSender}. *

      * Emails are plain text on purpose (no external images/styles to fetch, - * nothing for a mail client to block). A misconfigured or unreachable SMTP - * server never bubbles up as a 500 to the user — sign-up and password - * reset both still succeed, they just log a warning that no email went - * out, so the account/token itself is never lost because of a mail outage. + * nothing for a mail client to block). Transient SMTP failures are retried + * via the {@code mail} Resilience4j retry instance (same pattern as the + * {@code weatherApi} retry around the OpenWeatherMap client) before giving + * up; a misconfigured or unreachable SMTP server never bubbles up past this + * class. Retry is applied programmatically here, rather than via + * {@code @Retry}, because the send happens on a private helper called from + * within this same bean — an annotation-based retry would be silently + * skipped by Spring AOP's proxy on that kind of self-invocation. + *

      + * Sending itself is now invoked off the request thread: {@link MailService} + * is only ever called by {@link MailEventListener}, asynchronously, after + * the transaction that created the underlying token has committed. That + * keeps a mail outage from adding latency to (or breaking) sign-up, + * verification, or password-reset requests. */ @Service @Slf4j public class MailServiceImpl implements MailService { private final JavaMailSender mailSender; + private final Retry retry; private final String fromAddress; public MailServiceImpl(JavaMailSender mailSender, + RetryRegistry retryRegistry, @Value("${app.mail.from:no-reply@weatherviewer.local}") String fromAddress) { this.mailSender = mailSender; + this.retry = retryRegistry.retry("mail"); this.fromAddress = fromAddress; } @@ -55,17 +70,26 @@ public void sendPasswordResetEmail(String to, String firstName, String resetLink send(to, subject, body); } + /** + * Sends the message, retrying transient SMTP failures via the {@code mail} + * Resilience4j retry instance. If every attempt fails, the failure is + * logged and swallowed here rather than propagated — callers (in + * practice, {@link MailEventListener} running on its own thread) never + * need to handle a mail-specific exception. + */ private void send(String to, String subject, String body) { try { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(fromAddress); - message.setTo(to); - message.setSubject(subject); - message.setText(body); - mailSender.send(message); + retry.executeRunnable(() -> { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + mailSender.send(message); + }); log.info("Sent email '{}' to {}", subject, to); } catch (MailException e) { - log.warn("Failed to send email '{}' to {}: {}", subject, to, e.getMessage()); + log.warn("Failed to send email '{}' to {} after retries: {}", subject, to, e.getMessage()); } } From a41573706e871e6f633401b5a2a2e26c470370d5 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:39:30 +0300 Subject: [PATCH 52/62] test(mail): update MailServiceImplTest and VerificationServiceImplTest for new constructors --- .../service/impl/MailServiceImplTest.java | 11 +++++- .../impl/VerificationServiceImplTest.java | 35 ++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java index a7ed306..56bd0dd 100644 --- a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java @@ -1,5 +1,7 @@ package com.weatherviewer.service.impl; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,6 +13,8 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; @@ -29,7 +33,12 @@ class MailServiceImplTest { @BeforeEach void setUp() { - service = new MailServiceImpl(mailSender, FROM_ADDRESS); + RetryConfig fastRetryConfig = RetryConfig.custom() + .maxAttempts(2) + .waitDuration(Duration.ZERO) + .build(); + RetryRegistry retryRegistry = RetryRegistry.of(fastRetryConfig); + service = new MailServiceImpl(mailSender, retryRegistry, FROM_ADDRESS); } @Test diff --git a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java index 147a062..171c40c 100644 --- a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java @@ -1,5 +1,7 @@ package com.weatherviewer.service.impl; +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; import com.weatherviewer.exception.InvalidTokenException; import com.weatherviewer.model.User; import com.weatherviewer.model.VerificationToken; @@ -8,13 +10,13 @@ import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.repository.VerificationTokenRepository; -import com.weatherviewer.service.MailService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDateTime; @@ -23,7 +25,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -40,7 +41,7 @@ class VerificationServiceImplTest { private VerificationTokenRepository tokenRepository; @Mock - private MailService mailService; + private ApplicationEventPublisher eventPublisher; @Mock private PasswordEncoder passwordEncoder; @@ -49,7 +50,7 @@ class VerificationServiceImplTest { @BeforeEach void setUp() { - service = new VerificationServiceImpl(userRepository, tokenRepository, mailService, passwordEncoder, + service = new VerificationServiceImpl(userRepository, tokenRepository, eventPublisher, passwordEncoder, BASE_URL, VERIFICATION_TTL_HOURS, PASSWORD_RESET_TTL_HOURS); } @@ -103,12 +104,15 @@ void sendVerificationEmail_sendsMailWithLinkContainingBaseUrlAndToken() { verify(tokenRepository).save(tokenCaptor.capture()); String rawToken = tokenCaptor.getValue().getToken(); - ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); - verify(mailService).sendVerificationEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(VerificationEmailRequestedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); - String link = linkCaptor.getValue(); - assertThat(link).startsWith(BASE_URL + "/verify-email?token="); - assertThat(link).contains(rawToken); + VerificationEmailRequestedEvent event = eventCaptor.getValue(); + assertThat(event.email()).isEqualTo("john@example.com"); + assertThat(event.firstName()).isEqualTo("John"); + assertThat(event.verificationLink()).startsWith(BASE_URL + "/verify-email?token="); + assertThat(event.verificationLink()).contains(rawToken); } @Test @@ -220,9 +224,14 @@ void requestPasswordReset_existingEmail_invalidatesPriorTokensAndSendsMail() { assertThat(tokenCaptor.getValue().getExpiresAt()) .isBefore(LocalDateTime.now().plusHours(PASSWORD_RESET_TTL_HOURS + 1)); - ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); - verify(mailService).sendPasswordResetEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); - assertThat(linkCaptor.getValue()).startsWith(BASE_URL + "/reset-password?token="); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(PasswordResetEmailRequestedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + PasswordResetEmailRequestedEvent event = eventCaptor.getValue(); + assertThat(event.email()).isEqualTo("john@example.com"); + assertThat(event.firstName()).isEqualTo("John"); + assertThat(event.resetLink()).startsWith(BASE_URL + "/reset-password?token="); } @Test @@ -233,7 +242,7 @@ void requestPasswordReset_unregisteredEmail_doesNothingSilently() { verify(tokenRepository, never()).invalidateActiveTokens(any(UUID.class), any(TokenType.class)); verify(tokenRepository, never()).save(any(VerificationToken.class)); - verify(mailService, never()).sendPasswordResetEmail(anyString(), anyString(), anyString()); + verify(eventPublisher, never()).publishEvent(any()); } @Test From d5ff373a922971bfd51c9f3b25ba330659dfb44e Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:39:41 +0300 Subject: [PATCH 53/62] test(mail): add MailEventListenerTest --- .../service/impl/MailEventListenerTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java diff --git a/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java b/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java new file mode 100644 index 0000000..d6fc3cf --- /dev/null +++ b/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java @@ -0,0 +1,54 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; +import com.weatherviewer.service.MailService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * These tests call the listener methods directly (as the {@code @Async} / + * {@code @TransactionalEventListener} machinery only applies through a live + * Spring context) — the behavior worth unit-testing here is simply "each + * event is unpacked into the right {@link MailService} call". + */ +@ExtendWith(MockitoExtension.class) +class MailEventListenerTest { + + @Mock + private MailService mailService; + + @InjectMocks + private MailEventListener listener; + + @Test + void onVerificationEmailRequested_delegatesToMailService() { + VerificationEmailRequestedEvent event = new VerificationEmailRequestedEvent( + "john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + + listener.onVerificationEmailRequested(event); + + verify(mailService).sendVerificationEmail( + "john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + verifyNoMoreInteractions(mailService); + } + + @Test + void onPasswordResetEmailRequested_delegatesToMailService() { + PasswordResetEmailRequestedEvent event = new PasswordResetEmailRequestedEvent( + "jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + + listener.onPasswordResetEmailRequested(event); + + verify(mailService).sendPasswordResetEmail( + "jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + verifyNoMoreInteractions(mailService); + } + +} From 31ad04f5c07b297ade3f9c4f2b3814638d35f39d Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 28 Jul 2026 09:10:44 +0300 Subject: [PATCH 54/62] feat(config): configure MessageSource UTF-8 encoding and i18n setup --- .../weatherviewer/config/WebMvcConfig.java | 30 +++++++++++++++++++ src/main/resources/application.properties | 5 ++++ 2 files changed, 35 insertions(+) create mode 100644 src/main/java/com/weatherviewer/config/WebMvcConfig.java diff --git a/src/main/java/com/weatherviewer/config/WebMvcConfig.java b/src/main/java/com/weatherviewer/config/WebMvcConfig.java new file mode 100644 index 0000000..7b9af5a --- /dev/null +++ b/src/main/java/com/weatherviewer/config/WebMvcConfig.java @@ -0,0 +1,30 @@ +package com.weatherviewer.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; + +/** + * Lets any request switch the active locale by adding a {@code lang} query + * parameter (e.g. {@code ?lang=uk}). The new locale is then persisted on + * the session by {@link AppConfig#localeResolver()}, so it applies to + * subsequent requests too, not just the one carrying the parameter. + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Bean + public LocaleChangeInterceptor localeChangeInterceptor() { + LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); + interceptor.setParamName("lang"); + return interceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(localeChangeInterceptor()); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6d8610d..37012af 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,6 +17,11 @@ spring.jpa.open-in-view=false spring.mvc.hiddenmethod.filter.enabled=true spring.jpa.properties.hibernate.jdbc.time_zone=UTC +# --- Internationalization --- +spring.messages.basename=messages +spring.messages.encoding=UTF-8 +spring.messages.cache-duration=${MESSAGES_CACHE_SECONDS:0} + server.servlet.session.cookie.secure=true server.servlet.session.cookie.same-site=strict From 5f0a439c4b9d8c4fb4ec6fc656901087d2eea94a Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 28 Jul 2026 09:11:29 +0300 Subject: [PATCH 55/62] feat(i18n): add default and Ukrainian message properties bundles --- src/main/resources/messages.properties | 116 ++++++++++++++++++++++ src/main/resources/messages_uk.properties | 116 ++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 src/main/resources/messages.properties create mode 100644 src/main/resources/messages_uk.properties diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..55dbdfe --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,116 @@ +# Default (English) message bundle. + +# --- Header (header-component.html) --- +header.sign-in=Sign in +header.sign-up=Sign up +header.sign-out=Sign out +header.theme.dark-mode=Switch to dark mode +header.language=Language + +# --- Sign-in page (sign-in.html) --- +signin.title=Sign in +signin.email.label=Email +signin.email.placeholder=name@example.com +signin.password.label=Password +signin.password.placeholder=Password +signin.remember-me=Remember me +signin.forgot-password=Forgot password? +signin.submit=Sign in +signin.no-account=Don't have an account yet? - +signin.sign-up-link=Sign up +signin.resend.prompt=Didn't get a verification email? +signin.resend.button=Resend verification email + +# --- Sign-up page (sign-up.html) --- +signup.title=Sign up +signup.first-name.label=First name +signup.first-name.placeholder=John +signup.last-name.label=Last name +signup.last-name.placeholder=Doe +signup.repeat-password.label=Repeat password +signup.repeat-password.placeholder=Repeat password +signup.submit=Sign up +signup.have-account=Already have an account - +signup.sign-in-link=Sign in + +# --- Shared auth links --- +back-to-sign-in=Back to sign in + +# --- Forgot password page (forgot-password.html) --- +forgot-password.title=Forgot password +forgot-password.instructions=Enter the email address on your account and we'll send you a link to reset your password. +forgot-password.submit=Send reset link + +# --- Reset password page (reset-password.html) --- +reset-password.title=Reset password +reset-password.password.label=New password +reset-password.password.placeholder=New password +reset-password.repeat-password.label=Repeat new password +reset-password.repeat-password.placeholder=Repeat new password +reset-password.submit=Set new password + +# --- Alert toasts (fragments/alerts.html) --- +alerts.success-heading=Success +alerts.error-heading=Error +alerts.errors-heading=Errors +alerts.close=Close + +# --- Error page (error.html) --- +error.title=Oops! Something Went Wrong +error.message=We're sorry, but an unexpected error has occurred. Please try again later. +error.go-home=Go to Home + +# --- Footer (footer-component.html) --- +footer.design-elements=Design elements +footer.development=Development +footer.copyright=Copyright \u00A9 2025 Pidlisnyi Lev + +# --- Search box (search-component.html) --- +search.aria-label=Search for a location +search.placeholder=Enter the location name +search.submit=Search + +# --- Search results page (search.html) --- +search.results-title=Search results +search.no-data=No data +search.add-location=Add to tracked locations + +# --- Forecast page (forecast.html) --- +forecast.hourly-title=Hourly Forecast +forecast.daily-title=Daily Forecast + +# --- Profile page (profile.html) --- +profile.title=Account Settings +profile.first-name.placeholder=First Name +profile.last-name.placeholder=Last Name +profile.password.placeholder=New Password +profile.units.label=Units +profile.units.metric=Metric (\u00B0C, m/s) +profile.units.imperial=Imperial (\u00B0F, mph) +profile.save=Save Changes + +# --- Home / dashboard page (home.html) --- +home.current-weather=Current Weather +home.sort-by=Sort by: +home.sort.date=Date +home.sort.name-asc=Name \u2191 +home.sort.name-desc=Name \u2193 +home.sort.favorite-first=Favorites First +home.sort.favorites-only=Favorites Only +home.delete=Delete +home.add-favorite=Add to Favorites +home.remove-favorite=Remove from Favorites +home.feels-like=Feels like {0}{1} +home.min=Min {0}{1} +home.max=Max {0}{1} +home.clouds=Clouds {0}% +home.humidity=Humidity {0}% +home.pressure=Pressure {0} hPa +home.wind={0}\u00B0 {1} {2} +home.sunrise=Sunrise {0} +home.sunset=Sunset {0} +home.forecast-button=Forecast +home.no-location.title=Location Required for Weather Information +home.no-location.instructions=Please add a location to view weather. You can do it by using the search bar above. +home.pagination.previous=Previous +home.pagination.next=Next diff --git a/src/main/resources/messages_uk.properties b/src/main/resources/messages_uk.properties new file mode 100644 index 0000000..4e8c31a --- /dev/null +++ b/src/main/resources/messages_uk.properties @@ -0,0 +1,116 @@ +# Ukrainian translation. Keep keys in sync with messages.properties. + +# --- Header (header-component.html) --- +header.sign-in=Увійти +header.sign-up=Реєстрація +header.sign-out=Вийти +header.theme.dark-mode=Перемкнути на темну тему +header.language=Мова + +# --- Sign-in page (sign-in.html) --- +signin.title=Вхід +signin.email.label=Електронна пошта +signin.email.placeholder=name@example.com +signin.password.label=Пароль +signin.password.placeholder=Пароль +signin.remember-me=Запам'ятати мене +signin.forgot-password=Забули пароль? +signin.submit=Увійти +signin.no-account=Ще не маєте акаунта? - +signin.sign-up-link=Зареєструватися +signin.resend.prompt=Не отримали лист для підтвердження? +signin.resend.button=Надіслати повторно + +# --- Sign-up page (sign-up.html) --- +signup.title=Реєстрація +signup.first-name.label=Ім'я +signup.first-name.placeholder=Іван +signup.last-name.label=Прізвище +signup.last-name.placeholder=Петренко +signup.repeat-password.label=Повторіть пароль +signup.repeat-password.placeholder=Повторіть пароль +signup.submit=Зареєструватися +signup.have-account=Вже маєте акаунт - +signup.sign-in-link=Увійти + +# --- Shared auth links --- +back-to-sign-in=Повернутися до входу + +# --- Forgot password page (forgot-password.html) --- +forgot-password.title=Забули пароль +forgot-password.instructions=Введіть електронну адресу вашого акаунта, і ми надішлемо посилання для скидання пароля. +forgot-password.submit=Надіслати посилання + +# --- Reset password page (reset-password.html) --- +reset-password.title=Скидання пароля +reset-password.password.label=Новий пароль +reset-password.password.placeholder=Новий пароль +reset-password.repeat-password.label=Повторіть новий пароль +reset-password.repeat-password.placeholder=Повторіть новий пароль +reset-password.submit=Встановити новий пароль + +# --- Alert toasts (fragments/alerts.html) --- +alerts.success-heading=Успіх +alerts.error-heading=Помилка +alerts.errors-heading=Помилки +alerts.close=Закрити + +# --- Error page (error.html) --- +error.title=Ой! Щось пішло не так +error.message=На жаль, сталася непередбачена помилка. Спробуйте пізніше. +error.go-home=На головну + +# --- Footer (footer-component.html) --- +footer.design-elements=Елементи дизайну +footer.development=Розробка +footer.copyright=Copyright \u00A9 2025 Pidlisnyi Lev + +# --- Search box (search-component.html) --- +search.aria-label=Пошук місця +search.placeholder=Введіть назву місця +search.submit=Пошук + +# --- Search results page (search.html) --- +search.results-title=Результати пошуку +search.no-data=Немає даних +search.add-location=Додати до відстежуваних місць + +# --- Forecast page (forecast.html) --- +forecast.hourly-title=Погодинний прогноз +forecast.daily-title=Щоденний прогноз + +# --- Profile page (profile.html) --- +profile.title=Налаштування акаунта +profile.first-name.placeholder=Ім'я +profile.last-name.placeholder=Прізвище +profile.password.placeholder=Новий пароль +profile.units.label=Одиниці виміру +profile.units.metric=Метричні (\u00B0C, м/с) +profile.units.imperial=Імперські (\u00B0F, миль/год) +profile.save=Зберегти зміни + +# --- Home / dashboard page (home.html) --- +home.current-weather=Поточна погода +home.sort-by=Сортувати за: +home.sort.date=Датою +home.sort.name-asc=Назвою \u2191 +home.sort.name-desc=Назвою \u2193 +home.sort.favorite-first=Спочатку обрані +home.sort.favorites-only=Лише обрані +home.delete=Видалити +home.add-favorite=Додати до обраних +home.remove-favorite=Прибрати з обраних +home.feels-like=Відчувається як {0}{1} +home.min=Мін. {0}{1} +home.max=Макс. {0}{1} +home.clouds=Хмарність {0}% +home.humidity=Вологість {0}% +home.pressure=Тиск {0} гПа +home.wind={0}\u00B0 {1} {2} +home.sunrise=Схід сонця {0} +home.sunset=Захід сонця {0} +home.forecast-button=Прогноз +home.no-location.title=Потрібне місце для отримання погоди +home.no-location.instructions=Додайте місце, щоб переглянути погоду. Це можна зробити за допомогою рядка пошуку вище. +home.pagination.previous=Попередня +home.pagination.next=Наступна \ No newline at end of file From cabe59543b23d6390e4a1284f1d79a5f5e53c690 Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 28 Jul 2026 09:12:15 +0300 Subject: [PATCH 56/62] refactor(templates): update HTML views to use localized i18n message keys --- src/main/resources/templates/error.html | 6 +-- .../resources/templates/footer-component.html | 6 +-- src/main/resources/templates/forecast.html | 4 +- .../resources/templates/forgot-password.html | 13 ++--- .../resources/templates/fragments/alerts.html | 12 ++--- .../resources/templates/header-component.html | 20 ++++++-- src/main/resources/templates/home.html | 49 ++++++++++--------- src/main/resources/templates/profile.html | 26 +++++----- .../resources/templates/reset-password.html | 16 +++--- .../resources/templates/search-component.html | 6 +-- src/main/resources/templates/search.html | 6 +-- src/main/resources/templates/sign-in.html | 29 ++++++----- src/main/resources/templates/sign-up.html | 34 ++++++++----- 13 files changed, 127 insertions(+), 100 deletions(-) diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html index 85403df..a8a4d2d 100644 --- a/src/main/resources/templates/error.html +++ b/src/main/resources/templates/error.html @@ -7,9 +7,9 @@

      -

      Oops! Something Went Wrong

      -

      We're sorry, but an unexpected error has occurred. Please try again later.

      - Go to Home +

      Oops! Something Went Wrong

      +

      We're sorry, but an unexpected error has occurred. Please try again later.

      + Go to Home
      diff --git a/src/main/resources/templates/footer-component.html b/src/main/resources/templates/footer-component.html index d1294ad..77e6e56 100644 --- a/src/main/resources/templates/footer-component.html +++ b/src/main/resources/templates/footer-component.html @@ -2,10 +2,10 @@
      -

      Copyright © 2025 Pidlisnyi Lev

      +

      Copyright © 2025 Pidlisnyi Lev

      diff --git a/src/main/resources/templates/forecast.html b/src/main/resources/templates/forecast.html index 7b291a4..f48f288 100644 --- a/src/main/resources/templates/forecast.html +++ b/src/main/resources/templates/forecast.html @@ -13,7 +13,7 @@
      -

      Hourly Forecast

      +

      Hourly Forecast

      @@ -30,7 +30,7 @@

      Daily Forecast

      +

      Daily Forecast

      diff --git a/src/main/resources/templates/forgot-password.html b/src/main/resources/templates/forgot-password.html index e325bdf..6c20372 100644 --- a/src/main/resources/templates/forgot-password.html +++ b/src/main/resources/templates/forgot-password.html @@ -14,19 +14,20 @@
      -

      Forgot password

      -

      Enter the email address on your account and we'll send you a link to reset your password.

      +

      Forgot password

      +

      Enter the email address on your account and we'll send you a link to reset your password.

      - - + +
      - +
      diff --git a/src/main/resources/templates/fragments/alerts.html b/src/main/resources/templates/fragments/alerts.html index 6ce79f8..95c64d7 100644 --- a/src/main/resources/templates/fragments/alerts.html +++ b/src/main/resources/templates/fragments/alerts.html @@ -4,8 +4,8 @@ th:id="'successToast'" th:if="${successMessage != null}">
      - Success - + Success +
      Success message
      @@ -14,8 +14,8 @@ th:id="'errorToast'" th:if="${errorMessage != null}">
      - Error - + Error +
      Error message
      @@ -24,8 +24,8 @@ th:id="'errorsToast'" th:if="${errorMessages != null}">
      - Errors - + Errors +
        diff --git a/src/main/resources/templates/header-component.html b/src/main/resources/templates/header-component.html index b3dd6cf..635e0d3 100644 --- a/src/main/resources/templates/header-component.html +++ b/src/main/resources/templates/header-component.html @@ -11,13 +11,25 @@

        Weather Viewer

      - + +
      +
      diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index 63695ad..659e337 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -14,16 +14,16 @@
      -

      Current Weather

      +

      Current Weather

      - +
      @@ -75,19 +75,19 @@

      Location

      - +
      - +
      - +
      @@ -103,7 +103,7 @@

      Location

      -

      +

      @@ -117,15 +117,15 @@

    • - +
    • - +
    • - +
    • - +
    @@ -152,15 +152,15 @@

  • - +
  • - +
  • - +
  • - +
@@ -189,7 +189,8 @@

+ th:href="@{/forecast(lat=${item.key.latitude},lon=${item.key.longitude})}" + th:text="#{home.forecast-button}"> Forecast @@ -200,15 +201,15 @@

missing data -

Location Required for Weather Information

-

Please add a location to view weather. You can do it by using the search bar above.

+

Location Required for Weather Information

+

Please add a location to view weather. You can do it by using the search bar above.

diff --git a/src/main/resources/templates/profile.html b/src/main/resources/templates/profile.html index 23edacd..5ee682e 100644 --- a/src/main/resources/templates/profile.html +++ b/src/main/resources/templates/profile.html @@ -17,42 +17,42 @@
-

Account Settings

+

Account Settings

- - + +
- - + +
- - + +
- - + +
- +
- +
diff --git a/src/main/resources/templates/reset-password.html b/src/main/resources/templates/reset-password.html index 64aa10e..2967b2a 100644 --- a/src/main/resources/templates/reset-password.html +++ b/src/main/resources/templates/reset-password.html @@ -14,24 +14,26 @@
-

Reset password

+

Reset password

- - + +
- - + +
- +
diff --git a/src/main/resources/templates/search-component.html b/src/main/resources/templates/search-component.html index 04f51c0..da88ee8 100644 --- a/src/main/resources/templates/search-component.html +++ b/src/main/resources/templates/search-component.html @@ -1,8 +1,8 @@
- - +
diff --git a/src/main/resources/templates/search.html b/src/main/resources/templates/search.html index 0c1b5b1..ade0b5b 100644 --- a/src/main/resources/templates/search.html +++ b/src/main/resources/templates/search.html @@ -10,7 +10,7 @@
-

Search results

+

Search results

@@ -26,13 +26,13 @@
Name
State - No data + No data

- +
diff --git a/src/main/resources/templates/sign-in.html b/src/main/resources/templates/sign-in.html index c64bd4d..87724ec 100644 --- a/src/main/resources/templates/sign-in.html +++ b/src/main/resources/templates/sign-in.html @@ -14,40 +14,43 @@
-

Sign in

+

Sign in

- - + +
- - + +
- +
- Forgot password? + Forgot password?
- +
-

Don't have an account yet - - Sign up - Sign up +

+ Don't have an account yet - + Sign up + Sign up

-

Didn't get a verification email?

+

Didn't get a verification email?

- +
diff --git a/src/main/resources/templates/sign-up.html b/src/main/resources/templates/sign-up.html index e149a90..c50f519 100644 --- a/src/main/resources/templates/sign-up.html +++ b/src/main/resources/templates/sign-up.html @@ -12,39 +12,47 @@
-

Sign up

+

Sign up

- - + +
- - + +
- - + +
- - + +
- - + +
- +
-

Already have an account - Sign in

+

+ Already have an account - + Sign in +

From 4856345f7dac7de433febd8cd0b52fad197b87f6 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 29 Jul 2026 08:53:32 +0300 Subject: [PATCH 57/62] feat(weather-api): add WeatherApiLocale to resolve request locale to an OWM lang code --- .../service/integration/WeatherApiLocale.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java new file mode 100644 index 0000000..a89ab74 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java @@ -0,0 +1,42 @@ +package com.weatherviewer.service.integration; + +import org.springframework.context.i18n.LocaleContextHolder; + +import java.util.Set; + +/** + * Maps the current request's locale to a language code OpenWeatherMap + * accepts for its {@code lang} query parameter, controlling the language of + * weather {@code description} text in the API response. + *

+ * Used by both {@link WeatherApiClient} (to build the actual request) and + * {@link WeatherApiCache} (to key cached responses per resolved language — + * without that, a location's weather cached for one language would be + * served as-is to a request in another language, since the raw JSON, + * descriptions included, is what's cached). + */ +public final class WeatherApiLocale { + + /** + * Languages we ask OpenWeatherMap to translate descriptions into. + * Keep in sync with the {@code messages_XX.properties} bundles this + * application ships — no point requesting a language the UI itself + * can't render around. + */ + private static final Set SUPPORTED_LANGUAGES = Set.of("en", "uk"); + + private static final String DEFAULT_LANGUAGE = "en"; + + private WeatherApiLocale() { + } + + /** + * @return the current {@link LocaleContextHolder} locale's language, + * if OpenWeatherMap is known to support it, otherwise {@code "en"}. + */ + public static String resolve() { + String language = LocaleContextHolder.getLocale().getLanguage(); + return SUPPORTED_LANGUAGES.contains(language) ? language : DEFAULT_LANGUAGE; + } + +} From b148270a190903c898e5f4322e3f52230d2e8b8c Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 29 Jul 2026 08:53:49 +0300 Subject: [PATCH 58/62] feat(weather-api): request OpenWeatherMap descriptions in the resolved locale --- .../service/integration/WeatherApiClient.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java index 64c297b..534bc6c 100644 --- a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java +++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java @@ -178,24 +178,24 @@ private static String maskApiKey(String url) { return APPID_PATTERN.matcher(url).replaceAll("$1***"); } - /** Builds a request URL for a city-name-based endpoint, with metric units and English descriptions. */ + /** Builds a request URL for a city-name-based endpoint, with metric units and locale-appropriate descriptions. */ private String buildCityUrl(String suffix, String city) { return UriComponentsBuilder.fromUri(URI.create(baseApiUrl + suffix)) .queryParam("appid", apiKey) .queryParam("units", "metric") - .queryParam("lang", "en") + .queryParam("lang", WeatherApiLocale.resolve()) .queryParam("q", city) .encode(StandardCharsets.UTF_8) .build() .toUriString(); } - /** Builds a request URL for a coordinate-based endpoint, with metric units and English descriptions. */ + /** Builds a request URL for a coordinate-based endpoint, with metric units and locale-appropriate descriptions. */ private String buildCoordsUrl(String suffix, double latitude, double longitude) { return UriComponentsBuilder.fromUri(URI.create(baseApiUrl + suffix)) .queryParam("appid", apiKey) .queryParam("units", "metric") - .queryParam("lang", "en") + .queryParam("lang", WeatherApiLocale.resolve()) .queryParam("lat", latitude) .queryParam("lon", longitude) .encode(StandardCharsets.UTF_8) From 1a8e236b9f3eeef7d963dff34ea11e0013fc6a3f Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 29 Jul 2026 08:54:42 +0300 Subject: [PATCH 59/62] test(weather-api): pin LocaleContextHolder in WeatherApiClientTest, add uk/fallback cases --- .../integration/WeatherApiClientTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java b/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java index 5eb62e7..0e77d2c 100644 --- a/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java +++ b/src/test/java/com/weatherviewer/service/integration/WeatherApiClientTest.java @@ -16,6 +16,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.LoggerFactory; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientResponseException; @@ -23,6 +24,7 @@ import java.lang.reflect.Method; import java.net.URI; import java.util.List; +import java.util.Locale; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -62,6 +64,10 @@ void setUp() { ReflectionTestUtils.setField(client, "forecastApiUrlSuffix", "/data/2.5/forecast"); ReflectionTestUtils.setField(client, "geocodingApiUrlSuffix", "/geo/1.0/direct"); + // WeatherApiLocale.resolve() reads LocaleContextHolder; pin it so the + // "lang=en" assertions below don't depend on the host JVM's default locale. + LocaleContextHolder.setLocale(Locale.ENGLISH); + lenient().doReturn(requestHeadersUriSpec).when(restClient).get(); lenient().doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri(any(URI.class)); lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); @@ -74,6 +80,7 @@ void setUp() { @AfterEach void tearDown() { ((Logger) LoggerFactory.getLogger(WeatherApiClient.class)).detachAppender(logAppender); + LocaleContextHolder.resetLocaleContext(); } @Test @@ -242,6 +249,34 @@ void fetchCurrentWeatherByCity_buildsUrlWithMetricUnitsAndEnglishLang() throws E assertThat(uri).contains("appid=" + SECRET_API_KEY); } + @Test + void fetchCurrentWeatherByCity_ukrainianLocale_buildsUrlWithUkrainianLang() throws Exception { + LocaleContextHolder.setLocale(new Locale("uk")); + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("{}"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchCurrentWeatherByCity("Kyiv"); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + assertThat(uriCaptor.getValue().toString()).contains("lang=uk"); + } + + @Test + void fetchCurrentWeatherByCity_unsupportedLocale_fallsBackToEnglishLang() throws Exception { + LocaleContextHolder.setLocale(Locale.GERMAN); + JsonNode mockNode = mock(JsonNode.class); + when(responseSpec.body(String.class)).thenReturn("{}"); + when(objectMapper.readTree(anyString())).thenReturn(mockNode); + + client.fetchCurrentWeatherByCity("Kyiv"); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + assertThat(uriCaptor.getValue().toString()).contains("lang=en"); + } + @Test void fetchForecastByCity_buildsUrlWithForecastSuffix() throws Exception { JsonNode mockNode = mock(JsonNode.class); From 3ce61c738fca1ef8b112161fb4533613157d79a9 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 29 Jul 2026 08:54:53 +0300 Subject: [PATCH 60/62] test(weather-api): add WeatherApiLocaleTest --- .../integration/WeatherApiLocaleTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/test/java/com/weatherviewer/service/integration/WeatherApiLocaleTest.java diff --git a/src/test/java/com/weatherviewer/service/integration/WeatherApiLocaleTest.java b/src/test/java/com/weatherviewer/service/integration/WeatherApiLocaleTest.java new file mode 100644 index 0000000..00e3ba4 --- /dev/null +++ b/src/test/java/com/weatherviewer/service/integration/WeatherApiLocaleTest.java @@ -0,0 +1,42 @@ +package com.weatherviewer.service.integration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.LocaleContextHolder; + +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; + +class WeatherApiLocaleTest { + + @AfterEach + void tearDown() { + LocaleContextHolder.resetLocaleContext(); + } + + @Test + void resolve_english_returnsEn() { + LocaleContextHolder.setLocale(Locale.ENGLISH); + assertThat(WeatherApiLocale.resolve()).isEqualTo("en"); + } + + @Test + void resolve_ukrainian_returnsUk() { + LocaleContextHolder.setLocale(new Locale("uk")); + assertThat(WeatherApiLocale.resolve()).isEqualTo("uk"); + } + + @Test + void resolve_ukrainianRegionVariant_returnsUk() { + LocaleContextHolder.setLocale(new Locale("uk", "UA")); + assertThat(WeatherApiLocale.resolve()).isEqualTo("uk"); + } + + @Test + void resolve_unsupportedLanguage_fallsBackToEn() { + LocaleContextHolder.setLocale(Locale.GERMAN); + assertThat(WeatherApiLocale.resolve()).isEqualTo("en"); + } + +} From 52b214ca7e180a9bb0b3db23f05fb1b4134751fc Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 29 Jul 2026 08:55:51 +0300 Subject: [PATCH 61/62] feat(i18n): add weather-condition.* translated labels for the WeatherCondition enum --- src/main/resources/messages.properties | 12 +++++++++++- src/main/resources/messages_uk.properties | 9 +++++++++ src/main/resources/templates/forecast.html | 4 ++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 55dbdfe..75e2a6b 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -79,6 +79,16 @@ search.add-location=Add to tracked locations forecast.hourly-title=Hourly Forecast forecast.daily-title=Daily Forecast +# Fallback labels for both forecast sections when a real per-reading +weather-condition.THUNDERSTORM=Thunderstorm +weather-condition.DRIZZLE=Drizzle +weather-condition.RAIN=Rain +weather-condition.SNOW=Snow +weather-condition.ATMOSPHERE=Fog/Mist +weather-condition.CLEAR=Clear +weather-condition.CLOUDS=Clouds +weather-condition.UNDEFINED=No data + # --- Profile page (profile.html) --- profile.title=Account Settings profile.first-name.placeholder=First Name @@ -113,4 +123,4 @@ home.forecast-button=Forecast home.no-location.title=Location Required for Weather Information home.no-location.instructions=Please add a location to view weather. You can do it by using the search bar above. home.pagination.previous=Previous -home.pagination.next=Next +home.pagination.next=Next \ No newline at end of file diff --git a/src/main/resources/messages_uk.properties b/src/main/resources/messages_uk.properties index 4e8c31a..d6836ba 100644 --- a/src/main/resources/messages_uk.properties +++ b/src/main/resources/messages_uk.properties @@ -79,6 +79,15 @@ search.add-location=Додати до відстежуваних місць forecast.hourly-title=Погодинний прогноз forecast.daily-title=Щоденний прогноз +weather-condition.THUNDERSTORM=Гроза +weather-condition.DRIZZLE=Мряка +weather-condition.RAIN=Дощ +weather-condition.SNOW=Сніг +weather-condition.ATMOSPHERE=Туман +weather-condition.CLEAR=Ясно +weather-condition.CLOUDS=Хмарно +weather-condition.UNDEFINED=Немає даних + # --- Profile page (profile.html) --- profile.title=Налаштування акаунта profile.first-name.placeholder=Ім'я diff --git a/src/main/resources/templates/forecast.html b/src/main/resources/templates/forecast.html index f48f288..b03839f 100644 --- a/src/main/resources/templates/forecast.html +++ b/src/main/resources/templates/forecast.html @@ -22,7 +22,7 @@

Hourly Forecast

-

+

@@ -43,7 +43,7 @@

-

+

From ec2ec247a1de84d802914a9ec695430cd964ca14 Mon Sep 17 00:00:00 2001 From: podlLev Date: Wed, 29 Jul 2026 09:25:42 +0300 Subject: [PATCH 62/62] chore(release): bump version to 1.2.0 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 8d50792..2928ac7 100644 --- a/pom.xml +++ b/pom.xml @@ -10,9 +10,9 @@ com WeatherViewer - 1.1.0-RELEASE + 1.2.0 WeatherViewer - A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting. + A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting. Supports English and Ukrainian locales.