fix account and SMTP onboarding workflows

This commit is contained in:
sechmachine
2026-08-15 02:29:41 +07:00
parent 6181984cf8
commit 17fa25bb09
20 changed files with 988 additions and 85 deletions
@@ -35,7 +35,7 @@ class SecurityConfiguration {
.requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER))) .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")) .logout(logout -> logout.logoutSuccessUrl("/login?logout"))
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class) .addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
.build(); .build();
@@ -1,17 +1,18 @@
package com.lab.labtimesheet.feature.account.controller; package com.lab.labtimesheet.feature.account.controller;
import java.security.Principal; import java.security.Principal;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.GlobalRole; import com.lab.labtimesheet.feature.account.model.dto.ActivationForm;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand; import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
import com.lab.labtimesheet.feature.account.service.AccountService; 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.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller @Controller
class AccountController { class AccountController {
@@ -22,64 +23,56 @@ class AccountController {
} }
@GetMapping("/admin/accounts/new") @GetMapping("/admin/accounts/new")
String newAccount() { String newAccount(Model model) {
if (!model.containsAttribute("accountForm")) {
model.addAttribute("accountForm", new CreateAccountForm());
}
return "accounts/new"; return "accounts/new";
} }
@PostMapping("/admin/accounts") @PostMapping("/admin/accounts")
String create( String create(@Valid @ModelAttribute("accountForm") CreateAccountForm form, BindingResult bindingResult,
@RequestParam String email, Principal principal) {
@RequestParam String displayName, if (bindingResult.hasErrors()) {
@RequestParam GlobalRole role, return "accounts/new";
@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) {
try { try {
var result = accounts.create( var result = accounts.create(form.toCommand(), accounts.requireActiveAdminId(principal.getName()));
new CreateAccountCommand(
email, displayName, role, clean(studentCode), internshipStart, internshipEnd),
accounts.requireActiveAdminId(principal.getName()));
return result.deliverySucceeded() return result.deliverySucceeded()
? "redirect:/admin/accounts/new?created" ? "redirect:/admin/accounts/new?created"
: "redirect:/admin/accounts/new?deliveryFailed"; : "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) { } catch (IllegalArgumentException | IllegalStateException exception) {
model.addAttribute("error", exception.getMessage()); bindingResult.reject("account.invalid", exception.getMessage());
return "accounts/new"; return "accounts/new";
} }
} }
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
@GetMapping("/activate") @GetMapping("/activate")
String activationForm(@RequestParam String token, Model model) { String activationForm(@ModelAttribute("activationForm") ActivationForm form, Model model) {
model.addAttribute("token", token); if (form.getToken() == null || form.getToken().isBlank()) {
model.addAttribute("error", "This activation link is invalid or no longer usable");
}
return "accounts/activate"; return "accounts/activate";
} }
@PostMapping("/activate") @PostMapping("/activate")
String activate( String activate(@Valid @ModelAttribute("activationForm") ActivationForm form, BindingResult bindingResult) {
@RequestParam String token, if (bindingResult.hasErrors()) {
@RequestParam String password, form.clearPasswords();
@RequestParam String confirmPassword,
Model model) {
if (!password.equals(confirmPassword)) {
model.addAttribute("token", token);
model.addAttribute("error", "Passwords do not match");
return "accounts/activate"; return "accounts/activate";
} }
try { try {
if (accounts.activate(token, password)) { if (accounts.activate(form.getToken(), form.getPassword())) {
return "redirect:/login?activated"; 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) { } catch (IllegalArgumentException exception) {
model.addAttribute("error", exception.getMessage()); bindingResult.reject("activation.invalid", exception.getMessage());
} }
model.addAttribute("token", token); form.clearPasswords();
return "accounts/activate"; return "accounts/activate";
} }
} }
@@ -1,13 +1,16 @@
package com.lab.labtimesheet.feature.account.controller; 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 com.lab.labtimesheet.feature.account.service.BootstrapService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@Controller @Controller
@@ -20,21 +23,30 @@ class BootstrapController {
} }
@GetMapping @GetMapping
String form() { String form(Model model) {
requireOpen(); requireOpen();
if (!model.containsAttribute("bootstrapForm")) {
model.addAttribute("bootstrapForm", new BootstrapForm());
}
return "bootstrap/form"; return "bootstrap/form";
} }
@PostMapping @PostMapping
String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password, String create(@Valid @ModelAttribute("bootstrapForm") BootstrapForm form, BindingResult bindingResult) {
Model model) { requireOpen();
if (bindingResult.hasErrors()) {
form.setPassword(null);
return "bootstrap/form";
}
try { try {
if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) { if (bootstrap.bootstrap(form.getEmail(), form.getDisplayName(), form.getPassword())
return "redirect:/login"; == BootstrapService.BootstrapOutcome.CREATED) {
return "redirect:/admin/smtp?onboarding";
} }
throw new ResponseStatusException(HttpStatus.NOT_FOUND); throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} catch (IllegalArgumentException validation) { } catch (IllegalArgumentException validation) {
model.addAttribute("error", validation.getMessage()); bindingResult.reject("bootstrap.invalid", validation.getMessage());
form.setPassword(null);
return "bootstrap/form"; return "bootstrap/form";
} }
} }
@@ -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; }
}
@@ -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;
}
}
@@ -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; }
}
@@ -1,20 +1,33 @@
package com.lab.labtimesheet.feature.integration.controller; package com.lab.labtimesheet.feature.integration.controller;
import java.security.Principal; import java.security.Principal;
import java.util.List;
import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode; import com.lab.labtimesheet.feature.integration.model.dto.SmtpActionForm;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; 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.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.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller @Controller
@RequestMapping("/admin/smtp") @RequestMapping("/admin/smtp")
class SmtpController { class SmtpController {
private static final String DEFERRAL_STEP = SmtpController.class.getName() + ".deferralStep";
private static final List<String> 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 SmtpConfigurationService smtp;
private final AccountService accounts; private final AccountService accounts;
@@ -24,30 +37,108 @@ class SmtpController {
} }
@GetMapping @GetMapping
String form() { String form(Model model) {
return "smtp/form"; return renderForm(model, null);
} }
@PostMapping("/draft") @PostMapping("/draft")
String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode, String saveDraft(@Valid @ModelAttribute("smtpForm") SmtpForm form, BindingResult bindingResult,
@RequestParam(required = false) String username, @RequestParam(required = false) String password, Principal principal, Model model) {
@RequestParam String fromAddress, @RequestParam String fromName, Principal principal) { if (bindingResult.hasErrors()) {
smtp.saveDraft(adminId(principal), form.clearPassword();
new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName)); return renderForm(model, form);
return "redirect:/admin/smtp"; }
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") @PostMapping("/test")
String test(@RequestParam long draftId, Principal principal) { String test(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult,
smtp.testDraft(draftId, adminId(principal), principal.getName()); Principal principal, Model model) {
return "redirect:/admin/smtp"; 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") @PostMapping("/activate")
String activate(@RequestParam long draftId, Principal principal) { String activate(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult,
smtp.activate(draftId, adminId(principal)); 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"; 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) { private long adminId(Principal principal) {
return accounts.requireActiveAdminId(principal.getName()); return accounts.requireActiveAdminId(principal.getName());
@@ -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();
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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) {
}
@@ -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.EncryptedSecret;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; 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.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.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository; import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
@@ -78,6 +79,30 @@ public class SmtpConfigurationService {
return mailDelivery.isAvailable(); 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) @Transactional(readOnly = true)
public SmtpConnection activeConnection() { public SmtpConnection activeConnection() {
return mailDelivery.activeConnection(); return mailDelivery.activeConnection();
@@ -5,10 +5,16 @@
<main> <main>
<h1>Choose your password</h1> <h1>Choose your password</h1>
<p th:if="${error}" th:text="${error}" role="alert"></p> <p th:if="${error}" th:text="${error}" role="alert"></p>
<form method="post" th:action="@{/activate}"> <form method="post" th:action="@{/activate}" th:object="${activationForm}">
<input name="token" type="hidden" th:value="${token}"> <div th:if="${#fields.hasGlobalErrors()}" role="alert">
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
</div>
<input th:field="*{token}" type="hidden">
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label> <label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
<label>Confirm password <input name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label> <label>Confirm password <input name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<p th:if="${#fields.hasErrors('confirmPassword')}" th:errors="*{confirmPassword}" role="alert"></p>
<p th:if="${#fields.hasErrors('passwordConfirmed')}" th:errors="*{passwordConfirmed}" role="alert"></p>
<button type="submit">Activate account</button> <button type="submit">Activate account</button>
</form> </form>
</main> </main>
+15 -8
View File
@@ -4,25 +4,32 @@
<body> <body>
<main> <main>
<h1>Create account</h1> <h1>Create account</h1>
<p th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
<p th:if="${param.created}" role="status">Account created and activation email sent.</p> <p th:if="${param.created}" role="status">Account created and activation email sent.</p>
<p th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p> <p th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p>
<p th:if="${error}" th:text="${error}" role="alert"></p> <form method="post" th:action="@{/admin/accounts}" th:object="${accountForm}">
<form method="post" th:action="@{/admin/accounts}"> <div th:if="${#fields.hasGlobalErrors()}" role="alert">
<label>Email <input name="email" type="email" required autocomplete="off"></label> <p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
<label>Display name <input name="displayName" required autocomplete="off"></label> </div>
<label>Email <input th:field="*{email}" type="email" required autocomplete="off"></label>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
<label>Display name <input th:field="*{displayName}" required autocomplete="off"></label>
<p th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
<label>Role <label>Role
<select name="role" required> <select th:field="*{role}" required>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="MENTOR">Mentor</option> <option value="MENTOR">Mentor</option>
<option value="INTERN">Intern</option> <option value="INTERN">Intern</option>
</select> </select>
</label> </label>
<p th:if="${#fields.hasErrors('role')}" th:errors="*{role}" role="alert"></p>
<fieldset> <fieldset>
<legend>Intern details</legend> <legend>Intern details</legend>
<label>Student code <input name="studentCode" autocomplete="off"></label> <label>Student code <input th:field="*{studentCode}" autocomplete="off"></label>
<label>Internship start <input name="internshipStart" type="date"></label> <label>Internship start <input th:field="*{internshipStart}" type="date"></label>
<label>Internship end <input name="internshipEnd" type="date"></label> <label>Internship end <input th:field="*{internshipEnd}" type="date"></label>
</fieldset> </fieldset>
<p th:if="${#fields.hasErrors('internDetailsValid')}" th:errors="*{internDetailsValid}" role="alert"></p>
<button type="submit">Create account</button> <button type="submit">Create account</button>
</form> </form>
</main> </main>
@@ -4,11 +4,16 @@
<body> <body>
<main> <main>
<h1>Create the first administrator</h1> <h1>Create the first administrator</h1>
<p th:if="${error}" th:text="${error}" role="alert"></p> <form method="post" th:action="@{/bootstrap}" th:object="${bootstrapForm}">
<form method="post" th:action="@{/bootstrap}"> <div th:if="${#fields.hasGlobalErrors()}" role="alert">
<label>Email <input name="email" type="email" required autocomplete="email"></label> <p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
<label>Display name <input name="displayName" required autocomplete="name"></label> </div>
<label>Email <input th:field="*{email}" type="email" required autocomplete="email"></label>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
<label>Display name <input th:field="*{displayName}" required autocomplete="name"></label>
<p th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label> <label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
<button type="submit">Create administrator</button> <button type="submit">Create administrator</button>
</form> </form>
</main> </main>
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Defer SMTP configuration</title></head>
<body>
<main>
<h1>Defer SMTP configuration</h1>
<p role="alert" th:text="${deferralWarning}"></p>
<p th:text="|Confirmation ${deferralStep} of 5|"></p>
<a th:href="@{/admin/smtp}">Configure SMTP</a>
<form th:if="${deferralStep > 1}" method="post" th:action="@{/admin/smtp/defer/back}">
<button type="submit">Back</button>
</form>
<p th:if="${deferralStep == 1}"><a th:href="@{/admin/smtp}">Back</a></p>
<form th:if="${deferralStep < 5}" method="post" th:action="@{/admin/smtp/defer/next}">
<button type="submit">I understand; continue</button>
</form>
<form th:if="${deferralStep == 5}" method="post" th:action="@{/admin/smtp/defer/finish}">
<button type="submit">Finish without SMTP</button>
</form>
</main>
</body>
</html>
+32 -7
View File
@@ -4,16 +4,41 @@
<body> <body>
<main> <main>
<h1>SMTP configuration</h1> <h1>SMTP configuration</h1>
<form method="post" th:action="@{/admin/smtp/draft}"> <p th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
<label>Host <input name="host" required></label> <p th:if="${param.onboarding}">Configure SMTP now to enable account onboarding and recovery.</p>
<label>Port <input name="port" type="number" min="1" max="65535" required></label> <p th:if="${smtpStatus.active}" role="status">SMTP is active.</p>
<label>Security <select name="securityMode"><option>STARTTLS</option><option>TLS</option><option>NONE</option></select></label> <p th:if="${smtpStatus.draftId != null}" role="status">Draft saved.</p>
<label>Username <input name="username" autocomplete="username"></label> <p th:if="${smtpStatus.tested}" role="status">Test passed.</p>
<form method="post" th:action="@{/admin/smtp/draft}" th:object="${smtpForm}">
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
</div>
<label>Host <input th:field="*{host}" required></label>
<p th:if="${#fields.hasErrors('host')}" th:errors="*{host}" role="alert"></p>
<label>Port <input th:field="*{port}" type="number" min="1" max="65535" required></label>
<p th:if="${#fields.hasErrors('port')}" th:errors="*{port}" role="alert"></p>
<label>Security <select th:field="*{securityMode}"><option value="STARTTLS">STARTTLS</option><option value="TLS">TLS</option><option value="NONE">NONE</option></select></label>
<p th:if="${#fields.hasErrors('securityMode')}" th:errors="*{securityMode}" role="alert"></p>
<label>Username <input th:field="*{username}" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="new-password"></label> <label>Password <input name="password" type="password" autocomplete="new-password"></label>
<label>From address <input name="fromAddress" type="email" required></label> <p th:if="${#fields.hasErrors('authenticationComplete')}" th:errors="*{authenticationComplete}" role="alert"></p>
<label>From name <input name="fromName" required></label> <label>From address <input th:field="*{fromAddress}" type="email" required></label>
<p th:if="${#fields.hasErrors('fromAddress')}" th:errors="*{fromAddress}" role="alert"></p>
<label>From name <input th:field="*{fromName}" required></label>
<p th:if="${#fields.hasErrors('fromName')}" th:errors="*{fromName}" role="alert"></p>
<button type="submit">Save draft</button> <button type="submit">Save draft</button>
</form> </form>
<form th:if="${smtpStatus.draftId != null}" method="post" th:action="@{/admin/smtp/test}">
<input type="hidden" name="draftId" th:value="${smtpStatus.draftId}">
<button type="submit">Test connection</button>
</form>
<form th:if="${smtpStatus.draftId != null and smtpStatus.tested}" method="post" th:action="@{/admin/smtp/activate}">
<input type="hidden" name="draftId" th:value="${smtpStatus.draftId}">
<button type="submit">Activate SMTP</button>
</form>
<p th:if="${param.onboarding}">
<a th:href="@{/admin/smtp/defer}">Defer SMTP</a>
</p>
</main> </main>
</body> </body>
</html> </html>
@@ -43,7 +43,7 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("test") @ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class AccountWebIntegrationTest { class AccountWebIntegrationTest {
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@@ -139,6 +139,73 @@ class AccountWebIntegrationTest {
.andExpect(unauthenticated()); .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) @TestConfiguration(proxyBeanMethods = false)
static class MailProbeConfiguration { static class MailProbeConfiguration {
@Bean @Bean
@@ -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));
}
}
@@ -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<String> recipients = new ArrayList<>();
@Override
public void send(SmtpConnection connection, String recipient, String subject, String body) {
recipients.add(recipient);
}
}
}