From 17fa25bb0921718f780037cd8c55a956bbdf6b19 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:29:41 +0700 Subject: [PATCH] fix account and SMTP onboarding workflows --- .../config/SecurityConfiguration.java | 2 +- .../account/controller/AccountController.java | 69 ++++---- .../controller/BootstrapController.java | 26 ++- .../account/model/dto/ActivationForm.java | 43 +++++ .../account/model/dto/BootstrapForm.java | 48 ++++++ .../account/model/dto/CreateAccountForm.java | 82 +++++++++ .../controller/SmtpController.java | 125 ++++++++++++-- .../controller/SmtpWarningAdvice.java | 23 +++ .../integration/model/dto/SmtpActionForm.java | 19 +++ .../integration/model/dto/SmtpForm.java | 113 ++++++++++++ .../model/dto/SmtpSetupStatus.java | 21 +++ .../service/SmtpConfigurationService.java | 25 +++ .../templates/accounts/activate.html | 10 +- .../resources/templates/accounts/new.html | 23 ++- .../resources/templates/bootstrap/form.html | 13 +- src/main/resources/templates/smtp/defer.html | 22 +++ src/main/resources/templates/smtp/form.html | 39 ++++- .../controller/AccountWebIntegrationTest.java | 69 +++++++- ...BootstrapOnboardingWebIntegrationTest.java | 140 +++++++++++++++ .../SmtpOnboardingWebIntegrationTest.java | 161 ++++++++++++++++++ 20 files changed, 988 insertions(+), 85 deletions(-) create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java create mode 100644 src/main/resources/templates/smtp/defer.html create mode 100644 src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index 5fde489..e5f6f85 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -35,7 +35,7 @@ class SecurityConfiguration { .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER))) - .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true)) + .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", false)) .logout(logout -> logout.logoutSuccessUrl("/login?logout")) .addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class) .build(); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java index 621eccf..4518475 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java @@ -1,17 +1,18 @@ package com.lab.labtimesheet.feature.account.controller; import java.security.Principal; -import java.time.LocalDate; -import com.lab.labtimesheet.feature.account.model.GlobalRole; -import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand; +import com.lab.labtimesheet.feature.account.model.dto.ActivationForm; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm; import com.lab.labtimesheet.feature.account.service.AccountService; -import org.springframework.format.annotation.DateTimeFormat; +import jakarta.validation.Valid; +import org.springframework.dao.DataIntegrityViolationException; 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; @Controller class AccountController { @@ -22,64 +23,56 @@ class AccountController { } @GetMapping("/admin/accounts/new") - String newAccount() { + String newAccount(Model model) { + if (!model.containsAttribute("accountForm")) { + model.addAttribute("accountForm", new CreateAccountForm()); + } return "accounts/new"; } @PostMapping("/admin/accounts") - String create( - @RequestParam String email, - @RequestParam String displayName, - @RequestParam GlobalRole role, - @RequestParam(required = false) String studentCode, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipStart, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipEnd, - Principal principal, - Model model) { + String create(@Valid @ModelAttribute("accountForm") CreateAccountForm form, BindingResult bindingResult, + Principal principal) { + if (bindingResult.hasErrors()) { + return "accounts/new"; + } try { - var result = accounts.create( - new CreateAccountCommand( - email, displayName, role, clean(studentCode), internshipStart, internshipEnd), - accounts.requireActiveAdminId(principal.getName())); + var result = accounts.create(form.toCommand(), accounts.requireActiveAdminId(principal.getName())); return result.deliverySucceeded() ? "redirect:/admin/accounts/new?created" : "redirect:/admin/accounts/new?deliveryFailed"; + } catch (DataIntegrityViolationException duplicate) { + bindingResult.rejectValue("email", "account.email.duplicate", "An account with this email already exists"); + return "accounts/new"; } catch (IllegalArgumentException | IllegalStateException exception) { - model.addAttribute("error", exception.getMessage()); + bindingResult.reject("account.invalid", exception.getMessage()); return "accounts/new"; } } - private static String clean(String value) { - return value == null || value.isBlank() ? null : value.trim(); - } - @GetMapping("/activate") - String activationForm(@RequestParam String token, Model model) { - model.addAttribute("token", token); + String activationForm(@ModelAttribute("activationForm") ActivationForm form, Model model) { + if (form.getToken() == null || form.getToken().isBlank()) { + model.addAttribute("error", "This activation link is invalid or no longer usable"); + } return "accounts/activate"; } @PostMapping("/activate") - String activate( - @RequestParam String token, - @RequestParam String password, - @RequestParam String confirmPassword, - Model model) { - if (!password.equals(confirmPassword)) { - model.addAttribute("token", token); - model.addAttribute("error", "Passwords do not match"); + String activate(@Valid @ModelAttribute("activationForm") ActivationForm form, BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + form.clearPasswords(); return "accounts/activate"; } try { - if (accounts.activate(token, password)) { + if (accounts.activate(form.getToken(), form.getPassword())) { return "redirect:/login?activated"; } - model.addAttribute("error", "This activation link is invalid or no longer usable"); + bindingResult.reject("activation.invalid", "This activation link is invalid or no longer usable"); } catch (IllegalArgumentException exception) { - model.addAttribute("error", exception.getMessage()); + bindingResult.reject("activation.invalid", exception.getMessage()); } - model.addAttribute("token", token); + form.clearPasswords(); return "accounts/activate"; } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java index 0daa0dc..e7bd134 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java @@ -1,13 +1,16 @@ package com.lab.labtimesheet.feature.account.controller; +import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm; import com.lab.labtimesheet.feature.account.service.BootstrapService; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; 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.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.server.ResponseStatusException; @Controller @@ -20,21 +23,30 @@ class BootstrapController { } @GetMapping - String form() { + String form(Model model) { requireOpen(); + if (!model.containsAttribute("bootstrapForm")) { + model.addAttribute("bootstrapForm", new BootstrapForm()); + } return "bootstrap/form"; } @PostMapping - String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password, - Model model) { + String create(@Valid @ModelAttribute("bootstrapForm") BootstrapForm form, BindingResult bindingResult) { + requireOpen(); + if (bindingResult.hasErrors()) { + form.setPassword(null); + return "bootstrap/form"; + } try { - if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) { - return "redirect:/login"; + if (bootstrap.bootstrap(form.getEmail(), form.getDisplayName(), form.getPassword()) + == BootstrapService.BootstrapOutcome.CREATED) { + return "redirect:/admin/smtp?onboarding"; } throw new ResponseStatusException(HttpStatus.NOT_FOUND); } catch (IllegalArgumentException validation) { - model.addAttribute("error", validation.getMessage()); + bindingResult.reject("bootstrap.invalid", validation.getMessage()); + form.setPassword(null); return "bootstrap/form"; } } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java new file mode 100644 index 0000000..9026242 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java @@ -0,0 +1,43 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Validated activation submission. Password fields remain request-local and are never repopulated by the view. + */ +public class ActivationForm { + @NotBlank(message = "This activation link is invalid or no longer usable") + private String token; + + @NotBlank(message = "Password is required") + @Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters") + private String password; + + @NotBlank(message = "Password confirmation is required") + private String confirmPassword; + + /** + * Confirms both password entries agree without exposing either value. + * + * @return {@code true} when confirmation matches + */ + @AssertTrue(message = "Passwords do not match") + public boolean isPasswordConfirmed() { + return password != null && password.equals(confirmPassword); + } + + /** Clears both cleartext password values before rendering an error response. */ + public void clearPasswords() { + password = null; + confirmPassword = null; + } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getConfirmPassword() { return confirmPassword; } + public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java new file mode 100644 index 0000000..ffe1344 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java @@ -0,0 +1,48 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Validated browser input for creating the first administrator. + * The password is deliberately never copied into redirected state or repopulated after validation failure. + */ +public class BootstrapForm { + @NotBlank(message = "Email is required") + @Email(message = "Enter a valid email address") + @Size(max = 320, message = "Email must contain at most 320 characters") + private String email; + + @NotBlank(message = "Display name is required") + @Size(max = 120, message = "Display name must contain at most 120 characters") + private String displayName; + + @NotBlank(message = "Password is required") + @Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters") + private String password; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email == null ? null : email.trim(); + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName == null ? null : displayName.trim(); + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java new file mode 100644 index 0000000..b010d00 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java @@ -0,0 +1,82 @@ +package com.lab.labtimesheet.feature.account.model.dto; + +import java.time.LocalDate; + +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.format.annotation.DateTimeFormat; + +/** Validated, non-secret Admin input for creating an immutable-role account. */ +public class CreateAccountForm { + @NotBlank(message = "Email is required") + @Email(message = "Enter a valid email address") + @Size(max = 320, message = "Email must contain at most 320 characters") + private String email; + + @NotBlank(message = "Display name is required") + @Size(max = 120, message = "Display name must contain at most 120 characters") + private String displayName; + + @NotNull(message = "Role is required") + private GlobalRole role; + + @Size(max = 64, message = "Student code must contain at most 64 characters") + private String studentCode; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate internshipStart; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate internshipEnd; + + /** + * Validates the role-dependent internship fields and their inclusive date ordering. + * + * @return {@code true} when Intern details are complete, or absent for non-Intern roles + */ + @AssertTrue(message = "Intern details are required for Intern accounts and must use a valid date range") + public boolean isInternDetailsValid() { + if (role == null) { + return true; + } + if (role != GlobalRole.INTERN) { + return !hasText(studentCode) && internshipStart == null && internshipEnd == null; + } + return hasText(studentCode) && internshipStart != null && internshipEnd != null + && !internshipEnd.isBefore(internshipStart); + } + + /** + * Converts validated browser input to the account service command. + * + * @return normalized service command + */ + public CreateAccountCommand toCommand() { + return new CreateAccountCommand(email, displayName, role, clean(studentCode), internshipStart, internshipEnd); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String clean(String value) { + return hasText(value) ? value.trim() : null; + } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email == null ? null : email.trim(); } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName == null ? null : displayName.trim(); } + public GlobalRole getRole() { return role; } + public void setRole(GlobalRole role) { this.role = role; } + public String getStudentCode() { return studentCode; } + public void setStudentCode(String studentCode) { this.studentCode = studentCode; } + public LocalDate getInternshipStart() { return internshipStart; } + public void setInternshipStart(LocalDate internshipStart) { this.internshipStart = internshipStart; } + public LocalDate getInternshipEnd() { return internshipEnd; } + public void setInternshipEnd(LocalDate internshipEnd) { this.internshipEnd = internshipEnd; } +} 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 436a746..c802868 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 @@ -1,20 +1,33 @@ package com.lab.labtimesheet.feature.integration.controller; import java.security.Principal; +import java.util.List; import com.lab.labtimesheet.feature.account.service.AccountService; -import com.lab.labtimesheet.feature.integration.model.SecurityMode; -import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpActionForm; +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.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.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; @Controller @RequestMapping("/admin/smtp") class SmtpController { + private static final String DEFERRAL_STEP = SmtpController.class.getName() + ".deferralStep"; + private static final List DEFERRAL_WARNINGS = List.of( + "Account onboarding is disabled until SMTP is active.", + "Activation resend is disabled until SMTP is active.", + "Password recovery is disabled until SMTP is active.", + "Workflow email delivery is less immediate until SMTP is active.", + "I acknowledge this installation remains restricted until SMTP is active."); + private final SmtpConfigurationService smtp; private final AccountService accounts; @@ -24,29 +37,107 @@ class SmtpController { } @GetMapping - String form() { - return "smtp/form"; + String form(Model model) { + return renderForm(model, null); } @PostMapping("/draft") - String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode, - @RequestParam(required = false) String username, @RequestParam(required = false) String password, - @RequestParam String fromAddress, @RequestParam String fromName, Principal principal) { - smtp.saveDraft(adminId(principal), - new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName)); - return "redirect:/admin/smtp"; + String saveDraft(@Valid @ModelAttribute("smtpForm") SmtpForm form, BindingResult bindingResult, + Principal principal, Model model) { + if (bindingResult.hasErrors()) { + form.clearPassword(); + return renderForm(model, form); + } + try { + smtp.saveDraft(adminId(principal), form.toDraft()); + return "redirect:/admin/smtp?saved"; + } catch (IllegalArgumentException | IllegalStateException validation) { + bindingResult.reject("smtp.invalid", validation.getMessage()); + form.clearPassword(); + return renderForm(model, form); + } } @PostMapping("/test") - String test(@RequestParam long draftId, Principal principal) { - smtp.testDraft(draftId, adminId(principal), principal.getName()); - return "redirect:/admin/smtp"; + String test(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult, + Principal principal, Model model) { + if (bindingResult.hasErrors()) { + return renderActionError(model, bindingResult); + } + try { + smtp.testDraft(action.getDraftId(), adminId(principal), principal.getName()); + return "redirect:/admin/smtp?tested"; + } catch (IllegalArgumentException | IllegalStateException failure) { + bindingResult.reject("smtp.test.failed", failure.getMessage()); + return renderActionError(model, bindingResult); + } } @PostMapping("/activate") - String activate(@RequestParam long draftId, Principal principal) { - smtp.activate(draftId, adminId(principal)); - return "redirect:/admin/smtp"; + String activate(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult, + Principal principal, Model model) { + if (bindingResult.hasErrors()) { + return renderActionError(model, bindingResult); + } + try { + smtp.activate(action.getDraftId(), adminId(principal)); + return "redirect:/admin/smtp?activated"; + } catch (IllegalArgumentException | IllegalStateException failure) { + bindingResult.reject("smtp.activate.failed", failure.getMessage()); + return renderActionError(model, bindingResult); + } + } + + @GetMapping("/defer") + String deferral(HttpSession session, Model model) { + if (smtp.hasActiveConfiguration()) { + return "redirect:/admin/smtp"; + } + int step = deferralStep(session); + model.addAttribute("deferralStep", step); + model.addAttribute("deferralWarning", DEFERRAL_WARNINGS.get(step - 1)); + return "smtp/defer"; + } + + @PostMapping("/defer/next") + String nextDeferral(HttpSession session) { + session.setAttribute(DEFERRAL_STEP, Math.min(5, deferralStep(session) + 1)); + return "redirect:/admin/smtp/defer"; + } + + @PostMapping("/defer/back") + String previousDeferral(HttpSession session) { + session.setAttribute(DEFERRAL_STEP, Math.max(1, deferralStep(session) - 1)); + return "redirect:/admin/smtp/defer"; + } + + @PostMapping("/defer/finish") + String finishDeferral(HttpSession session) { + if (deferralStep(session) != 5) { + return "redirect:/admin/smtp/defer"; + } + session.removeAttribute(DEFERRAL_STEP); + return "redirect:/dashboard"; + } + + private String renderForm(Model model, SmtpForm submittedForm) { + var status = smtp.setupStatus(); + model.addAttribute("smtpStatus", status); + model.addAttribute("smtpAction", new SmtpActionForm()); + if (submittedForm == null) { + model.addAttribute("smtpForm", SmtpForm.from(status)); + } + return "smtp/form"; + } + + private String renderActionError(Model model, BindingResult bindingResult) { + model.addAttribute(BindingResult.MODEL_KEY_PREFIX + "smtpAction", bindingResult); + return renderForm(model, null); + } + + private static int deferralStep(HttpSession session) { + Object value = session.getAttribute(DEFERRAL_STEP); + return value instanceof Integer step && step >= 1 && step <= 5 ? step : 1; } private long adminId(Principal principal) { diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java new file mode 100644 index 0000000..aaf0d9e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpWarningAdvice.java @@ -0,0 +1,23 @@ +package com.lab.labtimesheet.feature.integration.controller; + +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +/** + * Supplies the persistent restricted-installation warning state to server-rendered views until a tested SMTP + * configuration is active. + */ +@ControllerAdvice +class SmtpWarningAdvice { + private final SmtpConfigurationService smtp; + + SmtpWarningAdvice(SmtpConfigurationService smtp) { + this.smtp = smtp; + } + + @ModelAttribute("smtpRestricted") + boolean smtpRestricted() { + return !smtp.hasActiveConfiguration(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java new file mode 100644 index 0000000..d0dad6b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java @@ -0,0 +1,19 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +/** Validated identifier submitted by the SMTP test and activation forms. */ +public class SmtpActionForm { + @NotNull(message = "SMTP draft is required") + @Positive(message = "SMTP draft is invalid") + private Long draftId; + + public Long getDraftId() { + return draftId; + } + + public void setDraftId(Long draftId) { + this.draftId = draftId; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java new file mode 100644 index 0000000..5810f35 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java @@ -0,0 +1,113 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Validated Admin input for an SMTP draft. The cleartext password exists only for the current request and is + * cleared before the form is rendered again. + */ +public class SmtpForm { + @NotBlank(message = "Host is required") + @Size(max = 255, message = "Host must contain at most 255 characters") + private String host; + + @Min(value = 1, message = "Port must be between 1 and 65535") + @Max(value = 65535, message = "Port must be between 1 and 65535") + private int port = 587; + + @NotNull(message = "Security mode is required") + private SecurityMode securityMode = SecurityMode.STARTTLS; + + @Size(max = 320, message = "Username must contain at most 320 characters") + private String username; + + @Size(max = 1024, message = "Password is too long") + private String password; + + @NotBlank(message = "From address is required") + @Email(message = "Enter a valid email address") + @Size(max = 320, message = "From address must contain at most 320 characters") + private String fromAddress; + + @NotBlank(message = "From name is required") + @Size(max = 120, message = "From name must contain at most 120 characters") + private String fromName; + + /** + * Ensures SMTP authentication is either fully configured or completely absent. + * + * @return {@code true} when username and password presence agree + */ + @AssertTrue(message = "SMTP username and password must be supplied together") + public boolean isAuthenticationComplete() { + return hasText(username) == hasText(password); + } + + /** + * Converts validated browser input into the service command. The password remains request-local until the + * service encrypts it. + * + * @return SMTP draft command + */ + public SmtpDraft toDraft() { + return new SmtpDraft(host, port, securityMode, clean(username), emptyToNull(password), fromAddress, fromName); + } + + /** + * Builds a safe form representation of an existing draft without decrypting or exposing its password. + * + * @param status current non-secret setup status + * @return form populated only with non-secret values + */ + public static SmtpForm from(SmtpSetupStatus status) { + SmtpForm form = new SmtpForm(); + if (status.draftId() != null) { + form.host = status.host(); + form.port = status.port(); + form.securityMode = status.securityMode(); + form.username = status.username(); + form.fromAddress = status.fromAddress(); + form.fromName = status.fromName(); + } + return form; + } + + /** Clears the request-local cleartext password before rendering. */ + public void clearPassword() { + password = null; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String clean(String value) { + return hasText(value) ? value.trim() : null; + } + + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + public SecurityMode getSecurityMode() { return securityMode; } + public void setSecurityMode(SecurityMode securityMode) { this.securityMode = securityMode; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getFromAddress() { return fromAddress; } + public void setFromAddress(String fromAddress) { this.fromAddress = fromAddress; } + public String getFromName() { return fromName; } + public void setFromName(String fromName) { this.fromName = fromName; } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java new file mode 100644 index 0000000..57e8062 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpSetupStatus.java @@ -0,0 +1,21 @@ +package com.lab.labtimesheet.feature.integration.model.dto; + +import com.lab.labtimesheet.feature.integration.model.SecurityMode; + +/** + * Non-secret snapshot used by Admin setup views. No encrypted or cleartext credential material crosses this + * service boundary. + * + * @param active whether a tested SMTP configuration is active + * @param draftId editable draft identifier, or {@code null} when no draft exists + * @param tested whether the current draft most recently passed its connection test + * @param host draft host + * @param port draft port + * @param securityMode draft transport security + * @param username draft username, or {@code null} + * @param fromAddress draft sender address + * @param fromName draft sender display name + */ +public record SmtpSetupStatus(boolean active, Long draftId, boolean tested, String host, int port, + SecurityMode securityMode, String username, String fromAddress, String fromName) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java index dc584c9..c46acb8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java @@ -8,6 +8,7 @@ import com.lab.labtimesheet.feature.integration.model.SmtpStatus; import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret; import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpSetupStatus; import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration; import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository; import org.springframework.core.env.Environment; @@ -78,6 +79,30 @@ public class SmtpConfigurationService { return mailDelivery.isAvailable(); } + /** + * Returns the non-secret SMTP state needed by the Admin setup page. + * Password ciphertext, nonce, and decrypted credentials are never included. + * + * @return current active flag and editable draft metadata + */ + @Transactional(readOnly = true) + public SmtpSetupStatus setupStatus() { + boolean active = configurations.existsByStatus(SmtpStatus.ACTIVE); + return configurations.findByStatus(SmtpStatus.DRAFT) + .map(draft -> new SmtpSetupStatus( + active, + draft.getId(), + draft.getTestedAt() != null, + draft.getHost(), + draft.getPort(), + draft.getSecurityMode(), + draft.getUsername(), + draft.getFromAddress(), + draft.getFromName())) + .orElseGet(() -> new SmtpSetupStatus(active, null, false, null, 587, + SecurityMode.STARTTLS, null, null, null)); + } + @Transactional(readOnly = true) public SmtpConnection activeConnection() { return mailDelivery.activeConnection(); diff --git a/src/main/resources/templates/accounts/activate.html b/src/main/resources/templates/accounts/activate.html index 69c785f..3dbad3b 100644 --- a/src/main/resources/templates/accounts/activate.html +++ b/src/main/resources/templates/accounts/activate.html @@ -5,10 +5,16 @@

Choose your password

-
- + +
+

+
+ +

+

+

diff --git a/src/main/resources/templates/accounts/new.html b/src/main/resources/templates/accounts/new.html index 8595aa5..2208861 100644 --- a/src/main/resources/templates/accounts/new.html +++ b/src/main/resources/templates/accounts/new.html @@ -4,25 +4,32 @@

Create account

+

This is a restricted installation until tested SMTP is active.

Account created and activation email sent.

Account created, but activation delivery failed.

-

-
- - + +
+

+
+ +

+ +

+

Intern details - - - + + +
+

diff --git a/src/main/resources/templates/bootstrap/form.html b/src/main/resources/templates/bootstrap/form.html index cbc74a0..82d4777 100644 --- a/src/main/resources/templates/bootstrap/form.html +++ b/src/main/resources/templates/bootstrap/form.html @@ -4,11 +4,16 @@

Create the first administrator

-

-
- - + +
+

+
+ +

+ +

+

diff --git a/src/main/resources/templates/smtp/defer.html b/src/main/resources/templates/smtp/defer.html new file mode 100644 index 0000000..9f38ab8 --- /dev/null +++ b/src/main/resources/templates/smtp/defer.html @@ -0,0 +1,22 @@ + + +Defer SMTP configuration + +
+

Defer SMTP configuration

+

+

+ Configure SMTP +
+ +
+

Back

+
+ +
+
+ +
+
+ + diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html index 8117d4b..d34459f 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -4,16 +4,41 @@

SMTP configuration

-
- - - - +

This is a restricted installation until tested SMTP is active.

+

Configure SMTP now to enable account onboarding and recovery.

+

SMTP is active.

+

Draft saved.

+

Test passed.

+ +
+

+
+ +

+ +

+ +

+ - - +

+ +

+ +

+
+ + +
+
+ + +
+

+ Defer SMTP +

diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java index ba040d3..9afb100 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java @@ -43,7 +43,7 @@ import org.springframework.test.web.servlet.MockMvc; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class AccountWebIntegrationTest { @Autowired private MockMvc mockMvc; @@ -139,6 +139,73 @@ class AccountWebIntegrationTest { .andExpect(unauthenticated()); } + @Test + void invalidAndDuplicateAccountFormsReturnActionableErrorsWithoutCreatingAnotherAccount() throws Exception { + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "not-an-email") + .param("displayName", "Safe display name") + .param("role", "INTERN") + .param("studentCode", "") + .param("internshipStart", "") + .param("internshipEnd", "")) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/new")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Intern details are required"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe display name"))); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "mentor@example.com") + .param("displayName", "Mentor One") + .param("role", "MENTOR")) + .andExpect(status().is3xxRedirection()); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", " MENTOR@EXAMPLE.COM ") + .param("displayName", "Duplicate Mentor") + .param("role", "MENTOR")) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/new")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("already exists"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Duplicate Mentor"))); + } + + @Test + void additionalAdminActivatesAndAuthenticatesWithoutChangingTheFirstAdmin() throws Exception { + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "second-admin@example.com") + .param("displayName", "Second Admin") + .param("role", "ADMIN")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/accounts/new?created")); + + String rawToken = mail.activationTokenFor("second-admin@example.com"); + mockMvc.perform(post("/activate") + .with(csrf()) + .param("token", rawToken) + .param("password", "new secure admin password") + .param("confirmPassword", "new secure admin password")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?activated")); + + mockMvc.perform(post("/login") + .with(csrf()) + .param("username", "second-admin@example.com") + .param("password", "new secure admin password")) + .andExpect(status().is3xxRedirection()) + .andExpect(authenticated().withRoles("ADMIN")); + assertThat(accounts.requireIdentityByEmail("admin@example.com").status()).isEqualTo(AccountStatus.ACTIVE); + assertThat(accounts.requireIdentityByEmail("admin@example.com").role()).isEqualTo(GlobalRole.ADMIN); + } + @TestConfiguration(proxyBeanMethods = false) static class MailProbeConfiguration { @Bean diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java new file mode 100644 index 0000000..49de963 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/BootstrapOnboardingWebIntegrationTest.java @@ -0,0 +1,140 @@ +package com.lab.labtimesheet.feature.account.controller; + +import static org.assertj.core.api.Assertions.assertThat; +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.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.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class BootstrapOnboardingWebIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Test + void bootstrapOffersSmtpAfterTheFirstAdminSignsIn() throws Exception { + MockHttpSession session = new MockHttpSession(); + var bootstrapResult = mockMvc.perform(post("/bootstrap") + .session(session) + .with(csrf()) + .param("email", " ADMIN@EXAMPLE.COM ") + .param("displayName", "First Admin") + .param("password", "correct horse battery staple")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?onboarding")) + .andReturn(); + assertThat(bootstrapResult.getRequest().getSession(false)).isSameAs(session); + + mockMvc.perform(get("/admin/smtp?onboarding").session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login")); + + mockMvc.perform(post("/login") + .session(session) + .with(csrf()) + .param("username", " ADMIN@EXAMPLE.COM ") + .param("password", "correct horse battery staple")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString( + "/admin/smtp?onboarding"))); + + mockMvc.perform(get("/admin/smtp?onboarding").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP now"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Defer SMTP"))); + } + + @Test + void fiveDistinctDeferralConfirmationsAreSequentialAndOnlyTheLastCanFinish() throws Exception { + initializeAdmin(); + var first = mockMvc.perform(get("/admin/smtp/defer") + .with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(view().name("smtp/defer")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Account onboarding is disabled"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Back"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Finish without SMTP")))) + .andReturn(); + MockHttpSession session = (MockHttpSession) first.getRequest().getSession(false); + assertThat(session).isNotNull(); + + assertStep(session, "Activation resend is disabled", false); + assertStep(session, "Password recovery is disabled", false); + assertStep(session, "Workflow email delivery is less immediate", false); + assertStep(session, "I acknowledge this installation remains restricted", true); + + mockMvc.perform(post("/admin/smtp/defer/finish") + .session(session) + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + } + + @Test + void bootstrapValidationRetainsSafeFieldsButNeverThePassword() throws Exception { + mockMvc.perform(post("/bootstrap") + .with(csrf()) + .param("email", "not-an-email") + .param("displayName", "Safe Admin Name") + .param("password", "must-not-be-rendered")) + .andExpect(status().isOk()) + .andExpect(view().name("bootstrap/form")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe Admin Name"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("must-not-be-rendered")))); + } + + private void initializeAdmin() throws Exception { + mockMvc.perform(post("/bootstrap") + .with(csrf()) + .param("email", "admin@example.com") + .param("displayName", "Admin") + .param("password", "correct horse battery staple")) + .andExpect(status().is3xxRedirection()); + } + + private void assertStep(MockHttpSession session, String warning, boolean finishVisible) throws Exception { + mockMvc.perform(post("/admin/smtp/defer/next") + .session(session) + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp/defer")); + + var matcher = finishVisible + ? org.hamcrest.Matchers.containsString("Finish without SMTP") + : org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("Finish without SMTP")); + mockMvc.perform(get("/admin/smtp/defer") + .session(session) + .with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString(warning))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Back"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP"))) + .andExpect(content().string(matcher)); + } +} 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 new file mode 100644 index 0000000..53e57f3 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -0,0 +1,161 @@ +package com.lab.labtimesheet.feature.integration.controller; + +import static org.assertj.core.api.Assertions.assertThat; +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.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.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.util.ArrayList; +import java.util.List; + +import com.lab.labtimesheet.config.TestcontainersConfiguration; +import com.lab.labtimesheet.feature.account.service.BootstrapService; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.service.SmtpProbe; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +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.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@Import({TestcontainersConfiguration.class, SmtpOnboardingWebIntegrationTest.ProbeConfiguration.class}) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class SmtpOnboardingWebIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private RecordingProbe probe; + + @BeforeEach + void initializeAdmin() { + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + } + + @Test + void adminCanSaveTestAndActivateSmtpWithVisibleStatus() throws Exception { + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("host", "mailpit") + .param("port", "1025") + .param("securityMode", "NONE") + .param("username", "smtp-user") + .param("password", "smtp-secret") + .param("fromAddress", "notifications@example.com") + .param("fromName", "Lab Timesheet")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?saved")); + + var draftPage = mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Draft saved"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Test connection"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Activate SMTP")))) + .andReturn(); + String html = draftPage.getResponse().getContentAsString(); + String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1"); + assertThat(draftId).matches("[0-9]+"); + + mockMvc.perform(post("/admin/smtp/test") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("draftId", draftId)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?tested")); + assertThat(probe.recipients).contains("admin@example.com"); + + mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Test passed"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Activate SMTP"))); + + mockMvc.perform(post("/admin/smtp/activate") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("draftId", draftId)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/smtp?activated")); + + mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("SMTP is active"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("restricted installation")))); + } + + @Test + void invalidDraftRetainsOnlySafeFieldsAndRendersValidationErrors() throws Exception { + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("host", "") + .param("port", "70000") + .param("securityMode", "STARTTLS") + .param("username", "smtp-user") + .param("password", "must-not-be-rendered") + .param("fromAddress", "not-an-email") + .param("fromName", "Safe sender name")) + .andExpect(status().isOk()) + .andExpect(view().name("smtp/form")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Host is required"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Port must be between"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Safe sender name"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("must-not-be-rendered")))); + } + + @Test + void restrictedWarningPersistsOnAdminPagesUntilActivationAndMutationsRequireCsrf() throws Exception { + mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("restricted installation"))); + + mockMvc.perform(post("/admin/smtp/draft") + .with(user("admin@example.com").roles("ADMIN")) + .param("host", "mailpit") + .param("port", "1025") + .param("securityMode", "NONE") + .param("fromAddress", "admin@example.com") + .param("fromName", "Lab Timesheet")) + .andExpect(status().isForbidden()); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ProbeConfiguration { + @Bean + @Primary + RecordingProbe recordingProbe() { + return new RecordingProbe(); + } + } + + static final class RecordingProbe implements SmtpProbe { + private final List recipients = new ArrayList<>(); + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + recipients.add(recipient); + } + } +}