diff --git a/docs/tests/web/smtp-mail-exception-sanitization.md b/docs/tests/web/smtp-mail-exception-sanitization.md new file mode 100644 index 0000000..f7dcb88 --- /dev/null +++ b/docs/tests/web/smtp-mail-exception-sanitization.md @@ -0,0 +1,76 @@ +# Test Evidence: Sanitized production mail exception feedback + +- **Test type:** Web +- **Requirement IDs:** `INT-005`, `INT-008` +- **Scenario IDs:** `AC-INT-002` (production mail-failure boundary) +- **Test class/method:** `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft` +- **Implementation commit:** `pending` + +## Protected behavior + +Spring Mail delivery failures from the production SMTP adapter return the fixed Admin guidance instead of escaping +the MVC request or exposing provider diagnostics. A failed probe does not mark the draft tested or enable activation. + +## Test method + +MockMvc saves a valid draft, then the test SMTP boundary throws Spring's production-shaped `MailSendException` with a +distinctive deterministic diagnostic. The authenticated CSRF-protected request crosses the real controller and SMTP +configuration service, and the rendered Thymeleaf response is inspected for the fixed message, raw-text absence, and +absence of the activation action. + +## Hand-derived expected result + +The response is HTTP 200 on `smtp/form`, contains the fixed operator guidance, omits the exception diagnostic, and +does not offer Activate SMTP because `markTested` was never reached. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +MailSendException escaped as ServletException with the distinctive diagnostic instead of rendering smtp/form. +BUILD FAILURE +PostgreSQL: 18.4 +``` + +## GREEN + +**Command** + +```text +./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test +Tests run: 22, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +PostgreSQL: 18.4 +``` + +## External-test boundaries + +The test exercises the production Spring Mail exception type without contacting an external SMTP server. It does not +prove live Mailpit/provider interoperability and contains no real credential or activation token. diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index 79954a5..b3fb507 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -9,6 +9,7 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm; import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import org.springframework.mail.MailException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -76,7 +77,7 @@ class SmtpController { try { smtp.testDraft(action.getDraftId(), adminId(principal), principal.getName()); return "redirect:/admin/smtp?tested"; - } catch (IllegalArgumentException | IllegalStateException failure) { + } catch (IllegalArgumentException | IllegalStateException | MailException failure) { bindingResult.reject("smtp.test.failed", TEST_FAILURE_MESSAGE); return renderActionError(model, bindingResult); } diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java index 8e8c115..607b318 100644 --- a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -26,6 +26,7 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; +import org.springframework.mail.MailSendException; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -158,7 +159,7 @@ class SmtpOnboardingWebIntegrationTest { .andReturn().getResponse().getContentAsString(); String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1"); String rawDiagnostic = "AUTH rejected for smtp-secret-raw-diagnostic"; - probe.failureMessage = rawDiagnostic; + probe.failure = new MailSendException(rawDiagnostic); mockMvc.perform(post("/admin/smtp/test") .with(user("admin@example.com").roles("ADMIN")) @@ -185,12 +186,12 @@ class SmtpOnboardingWebIntegrationTest { static final class RecordingProbe implements SmtpProbe { private final List recipients = new ArrayList<>(); - private String failureMessage; + private MailSendException failure; @Override public void send(SmtpConnection connection, String recipient, String subject, String body) { - if (failureMessage != null) { - throw new IllegalStateException(failureMessage); + if (failure != null) { + throw failure; } recipients.add(recipient); }