diff --git a/docs/SPRING_BOOT_4_ADOPTION_CHECKLIST.md b/docs/SPRING_BOOT_4_ADOPTION_CHECKLIST.md
index f0b68273d..b28f8e33d 100644
--- a/docs/SPRING_BOOT_4_ADOPTION_CHECKLIST.md
+++ b/docs/SPRING_BOOT_4_ADOPTION_CHECKLIST.md
@@ -21,24 +21,24 @@ This checklist is tailored to the current `wrongsecrets` codebase (Spring Boot `
### 1) Standardize HTTP error responses with `ProblemDetail`
-- [ ] Add a global `@RestControllerAdvice` for API endpoints that returns `ProblemDetail`.
-- [ ] Keep MVC HTML error handling as-is for Thymeleaf pages; only modernize JSON API errors.
-- [ ] Add tests that assert RFC 9457-style payload fields (`type`, `title`, `status`, `detail`, `instance`).
+- [x] Add a global `@RestControllerAdvice` for API endpoints that returns `ProblemDetail`.
+- [x] Keep MVC HTML error handling as-is for Thymeleaf pages; only modernize JSON API errors.
+- [x] Add tests that assert RFC 9457-style payload fields (`type`, `title`, `status`, `detail`, `instance`).
**Why now:** Reduces custom exception payload drift and improves API consistency.
### 2) Replace new `RestTemplate` usage with `RestClient`
-- [ ] Stop introducing any new `RestTemplate` usage.
-- [ ] Migrate existing bean in `WrongSecretsApplication` from `RestTemplate` to `RestClient.Builder`.
-- [ ] Migrate call sites incrementally (start with `SlackNotificationService`).
-- [ ] Add timeout and retry policy explicitly for outbound calls.
+- [x] Stop introducing any new `RestTemplate` usage.
+- [x] Migrate existing bean in `WrongSecretsApplication` from `RestTemplate` to `RestClient.Builder`.
+- [x] Migrate call sites incrementally (start with `SlackNotificationService`).
+- [x] Add timeout and retry policy explicitly for outbound calls.
**Current state:** `RestTemplate` bean and usage exist and can be migrated safely in phases.
### 3) Add/verify deprecation gate in CI
-- [ ] Run compile with deprecation warnings enabled in CI (`-Xlint:deprecation`).
+- [x] Run compile with deprecation warnings enabled in CI (`-Xlint:deprecation`).
- [ ] Fail build on newly introduced deprecations (can be soft-fail initially).
- [ ] Track remaining suppressions/deprecations as explicit TODOs.
@@ -139,8 +139,8 @@ This checklist is tailored to the current `wrongsecrets` codebase (Spring Boot `
## Definition of done for Boot 4 adoption
-- [ ] No new `RestTemplate` code introduced.
-- [ ] API errors are standardized on `ProblemDetail`.
-- [ ] Deprecation warnings are tracked and controlled in CI.
+- [x] No new `RestTemplate` code introduced.
+- [x] API errors are standardized on `ProblemDetail`.
+- [x] Deprecation warnings are tracked and controlled in CI.
- [ ] Observability baseline (metrics, traces, log correlation) is active in non-local profiles.
- [ ] Migration choices and rollout decisions are documented in `docs/`.
diff --git a/pom.xml b/pom.xml
index 943116e7e..90a7fa7d1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -591,6 +591,9 @@
25
25
+
+ -Xlint:deprecation
+
diff --git a/src/main/java/org/owasp/wrongsecrets/ApiExceptionAdvice.java b/src/main/java/org/owasp/wrongsecrets/ApiExceptionAdvice.java
new file mode 100644
index 000000000..7650caae1
--- /dev/null
+++ b/src/main/java/org/owasp/wrongsecrets/ApiExceptionAdvice.java
@@ -0,0 +1,55 @@
+package org.owasp.wrongsecrets;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.net.URI;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.server.ResponseStatusException;
+
+/**
+ * Global exception handler for REST API endpoints. Returns RFC 9457-style {@link ProblemDetail}
+ * responses. Scoped to {@link RestController} annotated beans only; Thymeleaf controllers are
+ * unaffected.
+ */
+@RestControllerAdvice(annotations = RestController.class)
+public class ApiExceptionAdvice {
+
+ /**
+ * Handles {@link ResponseStatusException} thrown from REST controllers and maps it to an RFC
+ * 9457-compliant {@link ProblemDetail} response.
+ *
+ * @param ex the exception to handle
+ * @param request the current HTTP request
+ * @return a {@link ProblemDetail} with status, title, detail and instance populated
+ */
+ @ExceptionHandler(ResponseStatusException.class)
+ public ProblemDetail handleResponseStatus(
+ ResponseStatusException ex, HttpServletRequest request) {
+ ProblemDetail pd = ProblemDetail.forStatus(ex.getStatusCode());
+ pd.setTitle(
+ ex.getReason() != null ? ex.getReason() : ex.getStatusCode().toString());
+ pd.setDetail(ex.getMessage());
+ pd.setInstance(URI.create(request.getRequestURI()));
+ return pd;
+ }
+
+ /**
+ * Handles unexpected exceptions thrown from REST controllers and maps them to an RFC 9457-
+ * compliant {@link ProblemDetail} response with HTTP 500 status.
+ *
+ * @param ex the exception to handle
+ * @param request the current HTTP request
+ * @return a {@link ProblemDetail} with status 500, title and detail populated
+ */
+ @ExceptionHandler(Exception.class)
+ public ProblemDetail handleGenericException(Exception ex, HttpServletRequest request) {
+ ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
+ pd.setTitle("Internal Server Error");
+ pd.setDetail(ex.getMessage());
+ pd.setInstance(URI.create(request.getRequestURI()));
+ return pd;
+ }
+}
diff --git a/src/main/java/org/owasp/wrongsecrets/WrongSecretsApplication.java b/src/main/java/org/owasp/wrongsecrets/WrongSecretsApplication.java
index 87fe83fb8..2093e092b 100644
--- a/src/main/java/org/owasp/wrongsecrets/WrongSecretsApplication.java
+++ b/src/main/java/org/owasp/wrongsecrets/WrongSecretsApplication.java
@@ -1,5 +1,6 @@
package org.owasp.wrongsecrets;
+import java.time.Duration;
import org.owasp.wrongsecrets.challenges.kubernetes.Vaultinjected;
import org.owasp.wrongsecrets.challenges.kubernetes.Vaultpassword;
import org.owasp.wrongsecrets.definitions.ChallengeDefinitionsConfiguration;
@@ -10,7 +11,8 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
-import org.springframework.web.client.RestTemplate;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
@SpringBootApplication
@EnableConfigurationProperties({Vaultpassword.class, Vaultinjected.class})
@@ -34,7 +36,10 @@ public RuntimeEnvironment runtimeEnvironment(
}
@Bean
- public RestTemplate restTemplate() {
- return new RestTemplate();
+ public RestClient restClient(RestClient.Builder builder) {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(Duration.ofSeconds(5));
+ factory.setReadTimeout(Duration.ofSeconds(10));
+ return builder.requestFactory(factory).build();
}
}
diff --git a/src/main/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationService.java b/src/main/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationService.java
index 3c47a1e23..c833d9062 100644
--- a/src/main/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationService.java
+++ b/src/main/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationService.java
@@ -5,11 +5,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.HttpEntity;
-import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
-import org.springframework.web.client.RestTemplate;
+import org.springframework.web.client.RestClient;
/** Service for sending Slack notifications when challenges are completed. */
@Service
@@ -17,12 +15,12 @@ public class SlackNotificationService {
private static final Logger logger = LoggerFactory.getLogger(SlackNotificationService.class);
- private final RestTemplate restTemplate;
+ private final RestClient restClient;
private final Optional challenge59;
public SlackNotificationService(
- RestTemplate restTemplate, @Autowired(required = false) Challenge59 challenge59) {
- this.restTemplate = restTemplate;
+ RestClient restClient, @Autowired(required = false) Challenge59 challenge59) {
+ this.restClient = restClient;
this.challenge59 = Optional.ofNullable(challenge59);
}
@@ -42,14 +40,16 @@ public void notifyChallengeCompletion(String challengeName, String userName, Str
try {
String message = buildCompletionMessage(challengeName, userName, userAgent);
SlackMessage slackMessage = new SlackMessage(message);
+ String webhookUrl = challenge59.get().getSlackWebhookUrl();
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
-
- HttpEntity request = new HttpEntity<>(slackMessage, headers);
+ restClient
+ .post()
+ .uri(webhookUrl)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(slackMessage)
+ .retrieve()
+ .toEntity(String.class);
- String webhookUrl = challenge59.get().getSlackWebhookUrl();
- restTemplate.postForEntity(webhookUrl, request, String.class);
logger.info(
"Successfully sent Slack notification for challenge completion: {}", challengeName);
diff --git a/src/test/java/org/owasp/wrongsecrets/ApiExceptionAdviceTest.java b/src/test/java/org/owasp/wrongsecrets/ApiExceptionAdviceTest.java
new file mode 100644
index 000000000..de9057481
--- /dev/null
+++ b/src/test/java/org/owasp/wrongsecrets/ApiExceptionAdviceTest.java
@@ -0,0 +1,63 @@
+package org.owasp.wrongsecrets;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+/** Tests that {@link ApiExceptionAdvice} returns RFC 9457-style {@code ProblemDetail} payloads. */
+class ApiExceptionAdviceTest {
+
+ private MockMvc mvc;
+
+ @BeforeEach
+ void setUp() {
+ mvc =
+ MockMvcBuilders.standaloneSetup(new TestRestController())
+ .setControllerAdvice(new ApiExceptionAdvice())
+ .build();
+ }
+
+ @Test
+ void shouldReturnProblemDetailWithRfc9457FieldsForResponseStatusException() throws Exception {
+ mvc.perform(get("/test/not-found").accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.status").value(404))
+ .andExpect(jsonPath("$.title").exists())
+ .andExpect(jsonPath("$.detail").exists())
+ .andExpect(jsonPath("$.instance").exists());
+ }
+
+ @Test
+ void shouldReturnProblemDetailWithRfc9457FieldsForGenericException() throws Exception {
+ mvc.perform(get("/test/error").accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isInternalServerError())
+ .andExpect(jsonPath("$.status").value(500))
+ .andExpect(jsonPath("$.title").value("Internal Server Error"))
+ .andExpect(jsonPath("$.detail").exists())
+ .andExpect(jsonPath("$.instance").exists());
+ }
+
+ @RestController
+ static class TestRestController {
+
+ @GetMapping("/test/not-found")
+ public String notFound() {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Resource not found");
+ }
+
+ @GetMapping("/test/error")
+ public String error() {
+ throw new RuntimeException("Unexpected failure");
+ }
+ }
+}
diff --git a/src/test/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationServiceTest.java b/src/test/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationServiceTest.java
index 0d8719215..29db62ead 100644
--- a/src/test/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationServiceTest.java
+++ b/src/test/java/org/owasp/wrongsecrets/challenges/docker/SlackNotificationServiceTest.java
@@ -1,32 +1,39 @@
package org.owasp.wrongsecrets.challenges.docker;
import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
-import com.fasterxml.jackson.databind.ObjectMapper;
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.http.HttpEntity;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.web.client.RestTemplate;
+import org.springframework.web.client.RestClient;
@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
class SlackNotificationServiceTest {
- @Mock private RestTemplate restTemplate;
+ @Mock private RestClient restClient;
+ @Mock private RestClient.RequestBodyUriSpec postSpec;
+ @Mock private RestClient.RequestBodySpec bodySpec;
+ @Mock private RestClient.ResponseSpec responseSpec;
@Mock private Challenge59 challenge59;
- private ObjectMapper objectMapper;
private SlackNotificationService slackNotificationService;
@BeforeEach
void setUp() {
- objectMapper = new ObjectMapper();
+ when(restClient.post()).thenReturn(postSpec);
+ when(postSpec.uri(anyString())).thenReturn(bodySpec);
+ when(bodySpec.contentType(any(MediaType.class))).thenReturn(bodySpec);
+ when(bodySpec.body(any())).thenReturn(bodySpec);
+ when(bodySpec.retrieve()).thenReturn(responseSpec);
}
@Test
@@ -35,17 +42,16 @@ void shouldSendNotificationWithUserAgentWhenSlackIsConfigured() {
String webhookUrl = "https://hooks.slack.com/services/T123456789/B123456789/abcdef123456";
String userAgent = "Mozilla/5.0 (Test Browser)";
when(challenge59.getSlackWebhookUrl()).thenReturn(webhookUrl);
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
- .thenReturn(ResponseEntity.ok("ok"));
+ when(responseSpec.toEntity(String.class)).thenReturn(ResponseEntity.ok("ok"));
- slackNotificationService = new SlackNotificationService(restTemplate, challenge59);
+ slackNotificationService = new SlackNotificationService(restClient, challenge59);
// When
slackNotificationService.notifyChallengeCompletion("challenge-1", "testuser", userAgent);
// Then
- verify(restTemplate, times(1))
- .postForEntity(eq(webhookUrl), any(HttpEntity.class), eq(String.class));
+ verify(restClient, times(1)).post();
+ verify(postSpec, times(1)).uri(webhookUrl);
}
@Test
@@ -54,22 +60,18 @@ void shouldIncludeUserAgentInMessageWhenProvided() {
String webhookUrl = "https://hooks.slack.com/services/T123456789/B123456789/abcdef123456";
String userAgent = "Cypress WrongSecrets E2E Tests";
when(challenge59.getSlackWebhookUrl()).thenReturn(webhookUrl);
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
- .thenReturn(ResponseEntity.ok("ok"));
+ when(responseSpec.toEntity(String.class)).thenReturn(ResponseEntity.ok("ok"));
- slackNotificationService = new SlackNotificationService(restTemplate, challenge59);
+ slackNotificationService = new SlackNotificationService(restClient, challenge59);
// When
slackNotificationService.notifyChallengeCompletion("challenge-1", "testuser", userAgent);
// Then
- ArgumentCaptor entityCaptor = ArgumentCaptor.forClass(HttpEntity.class);
- verify(restTemplate, times(1))
- .postForEntity(eq(webhookUrl), entityCaptor.capture(), eq(String.class));
-
- HttpEntity capturedEntity = entityCaptor.getValue();
+ ArgumentCaptor