Merge commit '692b23e9b9891d360882671d8247965b44920b2f' into work/reports-ui
# Conflicts: # src/main/resources/templates/accounts/activate.html # src/main/resources/templates/accounts/new.html # src/main/resources/templates/bootstrap/form.html
This commit is contained in:
@@ -6,10 +6,16 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
|
||||
import com.lab.labtimesheet.config.SecurityProperties;
|
||||
|
||||
/** Application entry point and root component-scan boundary for Lab Timesheet. */
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
public class LabtimesheetApplication {
|
||||
|
||||
/**
|
||||
* Starts the standalone Spring Boot process.
|
||||
*
|
||||
* @param args command-line arguments forwarded to Spring Boot
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LabtimesheetApplication.class, args);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,15 @@ package com.lab.labtimesheet;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||
|
||||
/** Configures the application when deployed as a traditional servlet-container WAR. */
|
||||
public class ServletInitializer extends SpringBootServletInitializer {
|
||||
|
||||
/**
|
||||
* Registers the same application source used by the standalone launcher.
|
||||
*
|
||||
* @param application servlet-container application builder
|
||||
* @return builder configured with the Lab Timesheet application source
|
||||
*/
|
||||
@Override
|
||||
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
||||
return application.sources(LabtimesheetApplication.class);
|
||||
|
||||
@@ -9,7 +9,12 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
|
||||
|
||||
/**
|
||||
* Defines form authentication, role-based Admin routes, CSRF protection, and response security headers.
|
||||
* Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization completes.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class SecurityConfiguration {
|
||||
@Bean
|
||||
@@ -27,11 +32,14 @@ class SecurityConfiguration {
|
||||
throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers("/bootstrap/**", "/activate/**", "/login", "/error", "/actuator/health")
|
||||
.requestMatchers(
|
||||
"/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**",
|
||||
"/actuator/health")
|
||||
.permitAll()
|
||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true))
|
||||
.headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER)))
|
||||
.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", false))
|
||||
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
||||
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
|
||||
.build();
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.Base64;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Security material used to encrypt integration credentials at rest. */
|
||||
@ConfigurationProperties("lab.security")
|
||||
public class SecurityProperties {
|
||||
private String masterKey;
|
||||
@@ -16,6 +17,12 @@ public class SecurityProperties {
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes and validates the configured AES-256 master key.
|
||||
*
|
||||
* @return a newly decoded 32-byte key
|
||||
* @throws IllegalStateException when the property is absent or does not decode to exactly 256 bits
|
||||
*/
|
||||
public byte[] decodedMasterKey() {
|
||||
if (masterKey == null || masterKey.isBlank()) {
|
||||
throw new IllegalStateException("lab.security.master-key is required");
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Provides the injectable Vietnam-zone clock used for server-authoritative business dates and time. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class TimeConfiguration {
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Ho_Chi_Minh");
|
||||
|
||||
@Bean
|
||||
Clock applicationClock() {
|
||||
return Clock.systemUTC();
|
||||
return Clock.system(BUSINESS_ZONE);
|
||||
}
|
||||
}
|
||||
|
||||
+61
-38
@@ -1,18 +1,24 @@
|
||||
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.hibernate.exception.ConstraintViolationException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Handles Admin account creation and single-use account activation browser flows. Known database uniqueness
|
||||
* constraints are mapped to their owning form fields without exposing persistence diagnostics.
|
||||
*/
|
||||
@Controller
|
||||
class AccountController {
|
||||
private final AccountService accounts;
|
||||
@@ -22,64 +28,81 @@ 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) {
|
||||
rejectUniquenessViolation(bindingResult, duplicate);
|
||||
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";
|
||||
}
|
||||
|
||||
private static void rejectUniquenessViolation(BindingResult bindingResult,
|
||||
DataIntegrityViolationException violation) {
|
||||
String constraintName = constraintName(violation);
|
||||
if ("uq_app_users_email_ci".equals(constraintName)) {
|
||||
bindingResult.rejectValue(
|
||||
"email", "account.email.duplicate", "An account with this email already exists");
|
||||
} else if ("uq_intern_profiles_student_code_ci".equals(constraintName)) {
|
||||
bindingResult.rejectValue("studentCode", "account.studentCode.duplicate",
|
||||
"An Intern with this student code already exists");
|
||||
} else {
|
||||
bindingResult.reject("account.unique", "Account details conflict with an existing account");
|
||||
}
|
||||
}
|
||||
|
||||
private static String constraintName(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current != null) {
|
||||
if (current instanceof ConstraintViolationException violation) {
|
||||
return violation.getConstraintName();
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/** Renders the project-owned form-login page used by Spring Security. */
|
||||
@Controller
|
||||
class AuthenticationController {
|
||||
@GetMapping("/login")
|
||||
|
||||
+19
-1
@@ -9,13 +9,31 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Hides all non-bootstrap application routes until durable first-Admin initialization completes.
|
||||
* Only bootstrap pages, health, public assets, and error rendering remain reachable beforehand.
|
||||
*/
|
||||
public class BootstrapAccessFilter extends OncePerRequestFilter {
|
||||
private final BootstrapService bootstrap;
|
||||
|
||||
/**
|
||||
* Creates the pre-bootstrap access guard.
|
||||
*
|
||||
* @param bootstrap durable installation-state service
|
||||
*/
|
||||
public BootstrapAccessFilter(BootstrapService bootstrap) {
|
||||
this.bootstrap = bootstrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTTP 404 for hidden routes before bootstrap so no authentication surface is exposed prematurely.
|
||||
*
|
||||
* @param request current HTTP request
|
||||
* @param response current HTTP response
|
||||
* @param chain remaining filter chain
|
||||
* @throws ServletException when downstream servlet processing fails
|
||||
* @throws IOException when response or downstream I/O fails
|
||||
*/
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
@@ -29,7 +47,7 @@ public class BootstrapAccessFilter extends OncePerRequestFilter {
|
||||
|
||||
private static boolean allowedBeforeBootstrap(String path) {
|
||||
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|
||||
|| path.equals("/actuator/health") || path.startsWith("/bootstrap-assets/")
|
||||
|| path.equals("/actuator/health") || path.startsWith("/assets/")
|
||||
|| path.equals("/error");
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -1,15 +1,19 @@
|
||||
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;
|
||||
|
||||
/** Renders and processes the one-time first-Admin installation form. */
|
||||
@Controller
|
||||
@RequestMapping("/bootstrap")
|
||||
class BootstrapController {
|
||||
@@ -20,21 +24,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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/** Maps the authenticated application root to the shared role-aware dashboard. */
|
||||
@Controller
|
||||
class HomeController {
|
||||
@GetMapping("/")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
/** Durable authentication lifecycle of a global account. */
|
||||
public enum AccountStatus {
|
||||
PENDING_ACTIVATION,
|
||||
ACTIVE,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
/** Immutable system-wide role assigned when an account is created. */
|
||||
public enum GlobalRole {
|
||||
ADMIN,
|
||||
MENTOR,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
/** Durable lifecycle of an Intern's internship independently of account activation. */
|
||||
public enum InternshipStatus {
|
||||
NOT_STARTED,
|
||||
ACTIVE,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
/** Purpose discriminator preventing one bearer-token class from serving another workflow. */
|
||||
public enum TokenPurpose {
|
||||
ACTIVATION,
|
||||
PASSWORD_RESET
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
/**
|
||||
* Result of creating a pending account and attempting its immediate activation delivery.
|
||||
*
|
||||
* @param userId created account identifier
|
||||
* @param deliverySucceeded whether the initial activation email was accepted by the configured SMTP boundary
|
||||
*/
|
||||
public record AccountCreation(long userId, boolean deliverySucceeded) {
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@ package com.lab.labtimesheet.feature.account.model.dto;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
|
||||
/**
|
||||
* Non-secret account identity exposed to other features without leaking JPA entities.
|
||||
*
|
||||
* @param id account identifier
|
||||
* @param email normalized email address
|
||||
* @param displayName user-facing name
|
||||
* @param role immutable global role
|
||||
* @param status current authentication lifecycle state
|
||||
*/
|
||||
public record AccountIdentity(
|
||||
long id,
|
||||
String email,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
/**
|
||||
* Current account metrics exposed to reporting without persistence coupling.
|
||||
*
|
||||
* @param activeAccounts accounts able to authenticate
|
||||
* @param pendingActivations accounts awaiting first-password activation
|
||||
* @param activeInternships Intern profiles in the active lifecycle state
|
||||
*/
|
||||
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -4,6 +4,16 @@ import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
|
||||
/**
|
||||
* Account-service creation input; internship fields are required only for the Intern role.
|
||||
*
|
||||
* @param email account email
|
||||
* @param displayName user-facing name
|
||||
* @param role immutable global role
|
||||
* @param studentCode Intern student code, otherwise {@code null}
|
||||
* @param internshipStart inclusive Intern start date, otherwise {@code null}
|
||||
* @param internshipEnd inclusive Intern end date, otherwise {@code null}
|
||||
*/
|
||||
public record CreateAccountCommand(
|
||||
String email,
|
||||
String displayName,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -17,6 +17,10 @@ import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
/**
|
||||
* Persistent global account with immutable role, authentication lifecycle, creator attribution, and optimistic
|
||||
* locking. Password hashes are absent until a pending account consumes its activation token.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "app_users")
|
||||
public class AppUser {
|
||||
@@ -57,6 +61,7 @@ public class AppUser {
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
/** Required by JPA; domain instances are created through named factories. */
|
||||
protected AppUser() {
|
||||
}
|
||||
|
||||
@@ -73,16 +78,42 @@ public class AppUser {
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the first already-active Admin used to initialize an installation.
|
||||
*
|
||||
* @param email normalized email
|
||||
* @param displayName user-facing name
|
||||
* @param passwordHash encoded password
|
||||
* @param now server timestamp
|
||||
* @return new active Admin entity without a creator
|
||||
*/
|
||||
public static AppUser bootstrapAdmin(String email, String displayName, String passwordHash, Instant now) {
|
||||
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a role-bearing account that cannot authenticate until activation assigns its password hash.
|
||||
*
|
||||
* @param email normalized email
|
||||
* @param displayName user-facing name
|
||||
* @param globalRole immutable global role
|
||||
* @param createdBy Admin creating the account
|
||||
* @param now server timestamp
|
||||
* @return new pending account entity
|
||||
*/
|
||||
public static AppUser pending(
|
||||
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
||||
return new AppUser(
|
||||
email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a pending account to active and records its encoded first password atomically.
|
||||
*
|
||||
* @param encodedPassword password-encoder output, never cleartext
|
||||
* @param now server activation timestamp
|
||||
* @throws IllegalStateException when the account is not pending activation
|
||||
*/
|
||||
public void activate(String encodedPassword, Instant now) {
|
||||
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
||||
throw new IllegalStateException("Only a pending account can activate");
|
||||
|
||||
@@ -12,6 +12,10 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
/**
|
||||
* Persistent internship lifecycle and inclusive eligibility dates for an Intern account.
|
||||
* The shared primary key is the owning account identifier without a cross-feature entity relationship.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "intern_profiles")
|
||||
public class InternProfile {
|
||||
@@ -56,6 +60,7 @@ public class InternProfile {
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
/** Required by JPA; domain instances are created through {@link #notStarted}. */
|
||||
protected InternProfile() {
|
||||
}
|
||||
|
||||
@@ -70,11 +75,27 @@ public class InternProfile {
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an internship awaiting its separately authorized start transition.
|
||||
*
|
||||
* @param userId owning Intern account identifier
|
||||
* @param studentCode university student code
|
||||
* @param internshipStartDate inclusive eligibility start date
|
||||
* @param internshipEndDate inclusive eligibility end date
|
||||
* @param now server timestamp
|
||||
* @return new not-started internship profile
|
||||
*/
|
||||
public static InternProfile notStarted(
|
||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a not-started internship to active.
|
||||
*
|
||||
* @param now server activation timestamp
|
||||
* @throws IllegalStateException when the internship already left the not-started state
|
||||
*/
|
||||
public void activate(Instant now) {
|
||||
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
||||
throw new IllegalStateException("Only a not-started internship can activate");
|
||||
|
||||
@@ -11,6 +11,7 @@ import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
|
||||
@Entity
|
||||
@Table(name = "system_state")
|
||||
public class SystemState {
|
||||
@@ -37,6 +38,7 @@ public class SystemState {
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
/** Required by JPA; Flyway creates the singleton row. */
|
||||
protected SystemState() {
|
||||
}
|
||||
|
||||
@@ -44,6 +46,13 @@ public class SystemState {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the installation initialized and retains the first Admin attribution.
|
||||
*
|
||||
* @param admin first active Admin
|
||||
* @param now server initialization timestamp
|
||||
* @throws IllegalStateException when initialization already completed
|
||||
*/
|
||||
public void initialize(AppUser admin, Instant now) {
|
||||
if (initialized) {
|
||||
throw new IllegalStateException("Bootstrap is already complete");
|
||||
|
||||
@@ -13,6 +13,10 @@ import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* Persistent one-time user-action token state. Only a defensive copy of the SHA-256 token hash is stored; raw
|
||||
* bearer tokens never enter this entity.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_action_tokens")
|
||||
public class UserActionToken {
|
||||
@@ -45,6 +49,7 @@ public class UserActionToken {
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
/** Required by JPA; domain instances are created through named factories. */
|
||||
protected UserActionToken() {
|
||||
}
|
||||
|
||||
@@ -57,15 +62,37 @@ public class UserActionToken {
|
||||
this.createdAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unused activation-token record from a cryptographic hash.
|
||||
*
|
||||
* @param userId account being activated
|
||||
* @param tokenHash 32-byte SHA-256 hash of the raw bearer token
|
||||
* @param expiresAt exclusive expiry instant
|
||||
* @param issuedByUserId Admin issuing the token
|
||||
* @param now server creation timestamp
|
||||
* @return new activation-token entity
|
||||
*/
|
||||
public static UserActionToken activation(
|
||||
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks single-use and exclusive-expiry state at a server timestamp.
|
||||
*
|
||||
* @param now server timestamp
|
||||
* @return {@code true} only before expiry and before use or invalidation
|
||||
*/
|
||||
public boolean isUsableAt(Instant now) {
|
||||
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes the token once.
|
||||
*
|
||||
* @param now server consumption timestamp
|
||||
* @throws IllegalStateException when expired, invalidated, or already used
|
||||
*/
|
||||
public void markUsed(Instant now) {
|
||||
if (!isUsableAt(now)) {
|
||||
throw new IllegalStateException("Activation token is not usable");
|
||||
@@ -73,6 +100,12 @@ public class UserActionToken {
|
||||
usedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates an unused token, idempotently, after its delivery fails.
|
||||
*
|
||||
* @param now server invalidation timestamp
|
||||
* @throws IllegalStateException when the token was already consumed
|
||||
*/
|
||||
public void invalidate(Instant now) {
|
||||
if (usedAt != null) {
|
||||
throw new IllegalStateException("A used token cannot be invalidated");
|
||||
@@ -94,6 +127,11 @@ public class UserActionToken {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a defensive copy of the persisted token hash.
|
||||
*
|
||||
* @return copied SHA-256 hash bytes
|
||||
*/
|
||||
public byte[] getTokenHash() {
|
||||
return Arrays.copyOf(tokenHash, tokenHash.length);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,30 @@ import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/** Account-feature persistence boundary for global users. */
|
||||
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
||||
/**
|
||||
* Finds an account by its canonical lower-case, trimmed email.
|
||||
*
|
||||
* @param email normalized email
|
||||
* @return matching account, if present
|
||||
*/
|
||||
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
||||
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
||||
|
||||
/**
|
||||
* Locks an account row for a lifecycle mutation until the current transaction completes.
|
||||
*
|
||||
* @param id account identifier
|
||||
* @return locked account, if present
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select u from AppUser u where u.id = :id")
|
||||
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
||||
|
||||
/** Counts accounts matching an immutable role and lifecycle state. */
|
||||
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
||||
|
||||
/** Counts accounts in a lifecycle state. */
|
||||
long countByAccountStatus(AccountStatus status);
|
||||
}
|
||||
|
||||
+10
@@ -10,14 +10,24 @@ import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/** Account-feature persistence boundary for Intern lifecycle and eligibility. */
|
||||
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
||||
/** Returns whether an Intern profile has the requested lifecycle state. */
|
||||
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
||||
|
||||
/** Returns whether an Intern is in the requested state throughout the supplied inclusive date point. */
|
||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||
|
||||
/** Counts Intern profiles in a lifecycle state. */
|
||||
long countByInternshipStatus(InternshipStatus status);
|
||||
|
||||
/**
|
||||
* Locks an Intern profile for lifecycle mutation until the current transaction completes.
|
||||
*
|
||||
* @param userId owning account identifier
|
||||
* @return locked profile, if present
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select p from InternProfile p where p.userId = :userId")
|
||||
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
||||
|
||||
+6
@@ -8,7 +8,13 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
/** Persistence boundary for the single durable installation-state row. */
|
||||
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
|
||||
/**
|
||||
* Locks the singleton row so concurrent bootstrap attempts cannot both create a first Admin.
|
||||
*
|
||||
* @return locked installation state
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select s from SystemState s where s.singletonId = 1")
|
||||
Optional<SystemState> findSingletonForUpdate();
|
||||
|
||||
+14
@@ -10,12 +10,26 @@ import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/** Persistence boundary for hashed, one-time account-action tokens. */
|
||||
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
||||
/**
|
||||
* Locks a token selected by hash and purpose for atomic single-use consumption.
|
||||
*
|
||||
* @param hash SHA-256 hash of the supplied raw bearer token
|
||||
* @param purpose expected workflow purpose
|
||||
* @return locked matching token, if present
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
||||
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
||||
@Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose);
|
||||
|
||||
/**
|
||||
* Locks a token by identifier for delivery-failure invalidation.
|
||||
*
|
||||
* @param id token identifier
|
||||
* @return locked token, if present
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from UserActionToken t where t.id = :id")
|
||||
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
||||
|
||||
@@ -30,6 +30,10 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Owns account creation, activation, identity lookup, and Intern eligibility boundaries.
|
||||
* Mutations use JPA transactions and expose DTOs rather than account entities to other features.
|
||||
*/
|
||||
@Service
|
||||
public class AccountService {
|
||||
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
||||
@@ -63,6 +67,15 @@ public class AccountService {
|
||||
this.publicOrigin = normalizeOrigin(publicOrigin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pending immutable-role account and sends its one-time activation link immediately.
|
||||
* Only the SHA-256 token hash is persisted; the raw token remains in memory for this delivery call. If delivery
|
||||
* fails, the token is invalidated in a separate transaction and the pending account remains for audit history.
|
||||
*
|
||||
* @param command validated account details
|
||||
* @param adminId active Admin creating the account
|
||||
* @return created account identifier and whether activation delivery succeeded
|
||||
*/
|
||||
public AccountCreation create(CreateAccountCommand command, long adminId) {
|
||||
ValidatedAccount account = validate(command);
|
||||
if (!mailDelivery.isAvailable()) {
|
||||
@@ -90,6 +103,14 @@ public class AccountService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a valid, unexpired activation bearer token once and assigns the first encoded password.
|
||||
* The token and user rows are locked in the surrounding transaction.
|
||||
*
|
||||
* @param rawToken raw token received from the activation link
|
||||
* @param password first password, containing 12 through 128 characters
|
||||
* @return {@code true} when activation completed; {@code false} for an invalid, expired, used, or stale token
|
||||
*/
|
||||
@Transactional
|
||||
public boolean activate(String rawToken, String password) {
|
||||
BootstrapService.requirePassword(password);
|
||||
@@ -113,6 +134,14 @@ public class AccountService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an active Intern's internship from {@code NOT_STARTED} to {@code ACTIVE} once the configured
|
||||
* internship start date has arrived in the application's business timezone.
|
||||
*
|
||||
* @param internUserId Intern account whose internship should start
|
||||
* @param adminId active Admin authorizing the state transition
|
||||
* @throws IllegalStateException when the internship start date has not arrived
|
||||
*/
|
||||
@Transactional
|
||||
public void activateInternship(long internUserId, long adminId) {
|
||||
AppUser admin = users.findById(adminId)
|
||||
@@ -124,11 +153,19 @@ public class AccountService {
|
||||
if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) {
|
||||
throw new IllegalArgumentException("An active Intern account is required");
|
||||
}
|
||||
internProfiles.findForUpdateByUserId(internUserId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"))
|
||||
.activate(clock.instant());
|
||||
InternProfile profile = internProfiles.findForUpdateByUserId(internUserId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"));
|
||||
if (LocalDate.now(clock).isBefore(profile.getInternshipStartDate())) {
|
||||
throw new IllegalStateException("Internship cannot activate before its start date");
|
||||
}
|
||||
profile.activate(clock.instant());
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes current account and internship state for dashboard consumers.
|
||||
*
|
||||
* @return active account, pending activation, and active internship counts
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public AccountSummary summary() {
|
||||
return new AccountSummary(
|
||||
@@ -137,18 +174,38 @@ public class AccountService {
|
||||
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an account boundary DTO by database identifier regardless of lifecycle state.
|
||||
*
|
||||
* @param userId account identifier
|
||||
* @return non-secret identity and lifecycle state
|
||||
* @throws IllegalArgumentException when the account does not exist
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public AccountIdentity requireIdentityById(long userId) {
|
||||
return users.findById(userId).map(AccountService::identity)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an account boundary DTO by normalized email regardless of lifecycle state.
|
||||
*
|
||||
* @param email email address, normalized by trimming and lower-casing
|
||||
* @return non-secret identity and lifecycle state
|
||||
* @throws IllegalArgumentException when the account does not exist
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public AccountIdentity requireIdentityByEmail(String email) {
|
||||
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the account and its internship are both currently active.
|
||||
*
|
||||
* @param userId account identifier
|
||||
* @return {@code true} only for an active Intern with an active internship
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isEligibleIntern(long userId) {
|
||||
return users.findById(userId)
|
||||
@@ -159,6 +216,14 @@ public class AccountService {
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks active Intern eligibility on an inclusive internship date range.
|
||||
*
|
||||
* @param userId account identifier
|
||||
* @param workDate server-derived business date being authorized
|
||||
* @return {@code true} only when account and internship are active and the date is within the internship
|
||||
* @throws IllegalArgumentException when {@code workDate} is {@code null}
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isEligibleIntern(long userId, LocalDate workDate) {
|
||||
if (workDate == null) {
|
||||
@@ -173,6 +238,13 @@ public class AccountService {
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the cross-feature identity of a currently eligible Intern.
|
||||
*
|
||||
* @param userId account identifier
|
||||
* @return non-secret account identity
|
||||
* @throws IllegalArgumentException when the account or internship is not active
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public AccountIdentity requireEligibleIntern(long userId) {
|
||||
if (!isEligibleIntern(userId)) {
|
||||
@@ -181,6 +253,13 @@ public class AccountService {
|
||||
return requireIdentityById(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an authenticated active Admin by normalized email.
|
||||
*
|
||||
* @param email authenticated principal name
|
||||
* @return Admin account identifier
|
||||
* @throws IllegalArgumentException when the account is not an active Admin
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long requireActiveAdminId(String email) {
|
||||
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
|
||||
@@ -188,6 +267,13 @@ public class AccountService {
|
||||
return requireActiveAdmin(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the identified account to be an active Admin.
|
||||
*
|
||||
* @param userId account identifier
|
||||
* @return the same identifier after authorization
|
||||
* @throws IllegalArgumentException when the account is missing or not an active Admin
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long requireActiveAdminId(long userId) {
|
||||
AppUser user = users.findById(userId)
|
||||
|
||||
@@ -11,6 +11,10 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Performs the one-time installation bootstrap guarded by the locked singleton system-state row.
|
||||
* Successful creation persists the first active Admin and initialization marker atomically.
|
||||
*/
|
||||
@Service
|
||||
public class BootstrapService {
|
||||
private final SystemStateRepository systemStates;
|
||||
@@ -26,6 +30,14 @@ public class BootstrapService {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the first active Admin exactly once.
|
||||
*
|
||||
* @param email first Admin email, normalized by trimming and lower-casing
|
||||
* @param displayName first Admin display name
|
||||
* @param password first Admin password, containing 12 through 128 characters
|
||||
* @return {@link BootstrapOutcome#CREATED} or {@link BootstrapOutcome#ALREADY_INITIALIZED}
|
||||
*/
|
||||
@Transactional
|
||||
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
|
||||
String normalizedEmail = normalizeEmail(email);
|
||||
@@ -44,15 +56,32 @@ public class BootstrapService {
|
||||
return BootstrapOutcome.CREATED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the durable installation state.
|
||||
*
|
||||
* @return {@code true} after the first Admin has been committed
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isInitialized() {
|
||||
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the canonical account lookup form of an email address.
|
||||
*
|
||||
* @param email email supplied at a trust boundary
|
||||
* @return trimmed, locale-independent lower-case email
|
||||
*/
|
||||
public static String normalizeEmail(String email) {
|
||||
return requireText(email, "Email").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the shared account password length boundary.
|
||||
*
|
||||
* @param password cleartext request value
|
||||
* @throws IllegalArgumentException when outside 12 through 128 characters
|
||||
*/
|
||||
public static void requirePassword(String password) {
|
||||
if (password == null || password.length() < 12 || password.length() > 128) {
|
||||
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
|
||||
@@ -66,6 +95,7 @@ public class BootstrapService {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/** Result of attempting the single allowed installation bootstrap. */
|
||||
public enum BootstrapOutcome {
|
||||
CREATED,
|
||||
ALREADY_INITIALIZED
|
||||
|
||||
+8
@@ -9,6 +9,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
|
||||
@Service
|
||||
class DatabaseUserDetailsService implements UserDetailsService {
|
||||
private final AppUserRepository users;
|
||||
@@ -17,6 +18,13 @@ class DatabaseUserDetailsService implements UserDetailsService {
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the normalized account and disables authentication unless its lifecycle state is active.
|
||||
*
|
||||
* @param username submitted email address
|
||||
* @return Spring Security user details with the immutable global role
|
||||
* @throws UsernameNotFoundException when no account has that normalized email
|
||||
*/
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
|
||||
+118
-17
@@ -1,20 +1,43 @@
|
||||
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.mail.MailException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Runs the Admin SMTP draft, connection-test, activation, and ordered setup-deferral browser workflows.
|
||||
* Cleartext passwords remain request-local and are cleared before any error view is rendered. Failures crossing the
|
||||
* SMTP adapter boundary are represented by fixed operator guidance rather than raw provider diagnostics.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/admin/smtp")
|
||||
class SmtpController {
|
||||
private static final String TEST_FAILURE_MESSAGE =
|
||||
"SMTP test failed. Verify the draft settings and server availability, then try again.";
|
||||
private static final String ACTIVATION_FAILURE_MESSAGE =
|
||||
"SMTP activation failed. Test the current draft again before activating it.";
|
||||
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 AccountService accounts;
|
||||
|
||||
@@ -24,29 +47,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 | MailException failure) {
|
||||
bindingResult.reject("smtp.test.failed", TEST_FAILURE_MESSAGE);
|
||||
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", ACTIVATION_FAILURE_MESSAGE);
|
||||
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("smtpActionError", bindingResult.getAllErrors().getFirst().getDefaultMessage());
|
||||
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) {
|
||||
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.integration.model;
|
||||
|
||||
/** Transport security mode used when opening an SMTP connection. */
|
||||
public enum SecurityMode {
|
||||
NONE,
|
||||
STARTTLS,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.integration.model;
|
||||
|
||||
/** Lifecycle state of a versioned SMTP configuration. */
|
||||
public enum SmtpStatus {
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
/**
|
||||
* AES-GCM output persisted for an integration credential; arrays are defensively copied at every boundary.
|
||||
*
|
||||
* @param ciphertext encrypted credential including the authentication tag
|
||||
* @param nonce unique 96-bit nonce used for this encryption
|
||||
* @param keyVersion key-rotation identifier
|
||||
*/
|
||||
public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
|
||||
/** Defensively copies both byte arrays before this value can cross the encryption boundary. */
|
||||
public EncryptedSecret {
|
||||
ciphertext = ciphertext.clone();
|
||||
nonce = nonce.clone();
|
||||
}
|
||||
|
||||
/** @return a defensive copy of the encrypted credential bytes */
|
||||
@Override
|
||||
public byte[] ciphertext() {
|
||||
return ciphertext.clone();
|
||||
}
|
||||
|
||||
/** @return a defensive copy of the AES-GCM nonce */
|
||||
@Override
|
||||
public byte[] nonce() {
|
||||
return nonce.clone();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
|
||||
/**
|
||||
* Complete request-local SMTP connection material passed only to the delivery adapter.
|
||||
* The cleartext password must never be persisted, logged, or exposed to views.
|
||||
*
|
||||
* @param host SMTP host
|
||||
* @param port SMTP port
|
||||
* @param securityMode transport security mode
|
||||
* @param username optional authentication username
|
||||
* @param password optional decrypted password, scoped to the immediate call
|
||||
* @param fromAddress envelope From address
|
||||
* @param fromName human-readable From name
|
||||
*/
|
||||
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
|
||||
String fromAddress, String fromName) {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
|
||||
/**
|
||||
* Admin SMTP draft command. Its optional cleartext password is request-local and is encrypted by the service before
|
||||
* persistence.
|
||||
*
|
||||
* @param host SMTP host
|
||||
* @param port SMTP port
|
||||
* @param securityMode transport security mode
|
||||
* @param username optional authentication username
|
||||
* @param password optional cleartext password for immediate encryption
|
||||
* @param fromAddress envelope From address
|
||||
* @param fromName human-readable From name
|
||||
*/
|
||||
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
|
||||
String fromAddress, String fromName) {
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
+45
@@ -16,6 +16,10 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
/**
|
||||
* Versioned SMTP configuration entity whose credentials remain AES-GCM encrypted at rest.
|
||||
* Draft edits clear test status; only a tested draft can activate; replaced active revisions are retained as retired.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "smtp_configurations")
|
||||
public class SmtpConfiguration {
|
||||
@@ -85,9 +89,19 @@ public class SmtpConfiguration {
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
/** Required by JPA; revisions are created through {@link #draft}. */
|
||||
protected SmtpConfiguration() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an editable SMTP revision with encrypted credential material.
|
||||
*
|
||||
* @param draft validated SMTP settings
|
||||
* @param password encrypted password, or {@code null} for unauthenticated SMTP
|
||||
* @param adminId active Admin creating the revision
|
||||
* @param now server timestamp
|
||||
* @return new draft revision
|
||||
*/
|
||||
public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) {
|
||||
SmtpConfiguration configuration = new SmtpConfiguration();
|
||||
configuration.status = SmtpStatus.DRAFT;
|
||||
@@ -97,6 +111,14 @@ public class SmtpConfiguration {
|
||||
return configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces editable settings and clears any previous successful-test marker.
|
||||
*
|
||||
* @param draft validated SMTP settings
|
||||
* @param password encrypted password, or {@code null}
|
||||
* @param now server update timestamp
|
||||
* @throws IllegalStateException when this revision is no longer a draft
|
||||
*/
|
||||
public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) {
|
||||
if (status != SmtpStatus.DRAFT) {
|
||||
throw new IllegalStateException("Only an SMTP draft can be edited");
|
||||
@@ -115,6 +137,13 @@ public class SmtpConfiguration {
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a successful external probe after its delivery adapter returns.
|
||||
*
|
||||
* @param adminId active Admin who performed the test
|
||||
* @param now server success timestamp
|
||||
* @throws IllegalStateException when this revision is no longer a draft
|
||||
*/
|
||||
public void markTested(long adminId, Instant now) {
|
||||
if (status != SmtpStatus.DRAFT) {
|
||||
throw new IllegalStateException("SMTP draft is no longer available");
|
||||
@@ -124,6 +153,13 @@ public class SmtpConfiguration {
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes a tested draft to the active delivery configuration.
|
||||
*
|
||||
* @param adminId active Admin authorizing activation
|
||||
* @param now server activation timestamp
|
||||
* @throws IllegalStateException when the draft has not passed a test
|
||||
*/
|
||||
public void activate(long adminId, Instant now) {
|
||||
if (status != SmtpStatus.DRAFT || testedAt == null) {
|
||||
throw new IllegalStateException("SMTP draft must pass a test before activation");
|
||||
@@ -134,6 +170,13 @@ public class SmtpConfiguration {
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains but disables a replaced active revision.
|
||||
*
|
||||
* @param adminId active Admin activating its successor
|
||||
* @param now server retirement timestamp
|
||||
* @throws IllegalStateException when this revision is not active
|
||||
*/
|
||||
public void retire(long adminId, Instant now) {
|
||||
if (status != SmtpStatus.ACTIVE) {
|
||||
throw new IllegalStateException("Only active SMTP can be retired");
|
||||
@@ -172,10 +215,12 @@ public class SmtpConfiguration {
|
||||
return username;
|
||||
}
|
||||
|
||||
/** @return a defensive copy of encrypted password bytes, or {@code null} */
|
||||
public byte[] getPasswordCiphertext() {
|
||||
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
||||
}
|
||||
|
||||
/** @return a defensive copy of the AES-GCM nonce, or {@code null} */
|
||||
public byte[] getPasswordNonce() {
|
||||
return passwordNonce == null ? null : passwordNonce.clone();
|
||||
}
|
||||
|
||||
+10
@@ -8,11 +8,21 @@ import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
|
||||
/** Integration-feature persistence boundary for retained SMTP revisions. */
|
||||
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
|
||||
/** Finds the single revision in a given lifecycle state. */
|
||||
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
|
||||
|
||||
/** Returns whether a revision exists in a lifecycle state. */
|
||||
boolean existsByStatus(SmtpStatus status);
|
||||
|
||||
/**
|
||||
* Locks the identified revision in the expected state for atomic activation.
|
||||
*
|
||||
* @param id SMTP revision identifier
|
||||
* @param status required current lifecycle state
|
||||
* @return locked revision, if present
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
|
||||
}
|
||||
|
||||
+39
-7
@@ -1,18 +1,40 @@
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Sends immediate SMTP messages through a freshly configured JavaMail client.
|
||||
* Connections are bounded by finite network timeouts so an Admin test or
|
||||
* activation delivery cannot block a request indefinitely.
|
||||
*/
|
||||
@Component
|
||||
class JavaMailSmtpProbe implements SmtpProbe {
|
||||
private static final String TIMEOUT_MILLIS = "5000";
|
||||
|
||||
private final Supplier<JavaMailSenderImpl> senderFactory;
|
||||
|
||||
JavaMailSmtpProbe() {
|
||||
this(JavaMailSenderImpl::new);
|
||||
}
|
||||
|
||||
JavaMailSmtpProbe(Supplier<JavaMailSenderImpl> senderFactory) {
|
||||
this.senderFactory = senderFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
JavaMailSenderImpl sender = new JavaMailSenderImpl();
|
||||
JavaMailSenderImpl sender = senderFactory.get();
|
||||
sender.setHost(connection.host());
|
||||
sender.setPort(connection.port());
|
||||
sender.setUsername(connection.username());
|
||||
@@ -24,11 +46,21 @@ class JavaMailSmtpProbe implements SmtpProbe {
|
||||
} else if (connection.securityMode() == SecurityMode.TLS) {
|
||||
sender.setProtocol("smtps");
|
||||
}
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(connection.fromAddress());
|
||||
message.setTo(recipient);
|
||||
message.setSubject(subject);
|
||||
message.setText(body);
|
||||
String propertyPrefix = connection.securityMode() == SecurityMode.TLS ? "mail.smtps" : "mail.smtp";
|
||||
properties.setProperty(propertyPrefix + ".connectiontimeout", TIMEOUT_MILLIS);
|
||||
properties.setProperty(propertyPrefix + ".timeout", TIMEOUT_MILLIS);
|
||||
properties.setProperty(propertyPrefix + ".writetimeout", TIMEOUT_MILLIS);
|
||||
|
||||
MimeMessage message = sender.createMimeMessage();
|
||||
try {
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
|
||||
helper.setFrom(connection.fromAddress(), connection.fromName());
|
||||
helper.setTo(recipient);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body);
|
||||
} catch (MessagingException | UnsupportedEncodingException exception) {
|
||||
throw new IllegalStateException("Unable to construct SMTP message", exception);
|
||||
}
|
||||
sender.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -7,6 +7,10 @@ import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepo
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Cross-feature email delivery boundary backed by the single active SMTP revision.
|
||||
* Stored credentials are decrypted only while constructing the immediate adapter call.
|
||||
*/
|
||||
@Service
|
||||
public class MailDeliveryService {
|
||||
private final SmtpConfigurationRepository configurations;
|
||||
@@ -19,15 +23,34 @@ public class MailDeliveryService {
|
||||
this.probe = probe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether workflows may emit required email.
|
||||
*
|
||||
* @return {@code true} when an active tested SMTP revision exists
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isAvailable() {
|
||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one immediate message through the active configuration.
|
||||
*
|
||||
* @param recipient destination email address
|
||||
* @param subject message subject
|
||||
* @param body plain-text message body
|
||||
* @throws IllegalStateException when no active configuration exists or delivery fails
|
||||
*/
|
||||
public void send(String recipient, String subject, String body) {
|
||||
probe.send(activeConnection(), recipient, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves request-local connection material from the active encrypted configuration.
|
||||
*
|
||||
* @return complete connection values, including the transient decrypted password
|
||||
* @throws IllegalStateException when SMTP is not active
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||
|
||||
@@ -11,6 +11,7 @@ import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Encrypts SMTP credentials with AES-256-GCM using a fresh nonce per stored revision. */
|
||||
@Component
|
||||
public class SecretCipher {
|
||||
private static final int NONCE_BYTES = 12;
|
||||
|
||||
+63
@@ -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;
|
||||
@@ -15,6 +16,10 @@ import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Owns the Admin SMTP revision workflow: save an encrypted draft, test it, then atomically activate it.
|
||||
* A changed draft loses prior test status, and an active revision is retired when its tested successor activates.
|
||||
*/
|
||||
@Service
|
||||
public class SmtpConfigurationService {
|
||||
private final SmtpConfigurationRepository configurations;
|
||||
@@ -37,6 +42,14 @@ public class SmtpConfigurationService {
|
||||
this.mailDelivery = mailDelivery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or replaces the editable draft after validating Admin authority and environment transport rules.
|
||||
* Any supplied password is encrypted before persistence and prior test status is cleared.
|
||||
*
|
||||
* @param adminId active Admin saving the draft
|
||||
* @param draft SMTP settings and optional request-local password
|
||||
* @return persisted draft identifier
|
||||
*/
|
||||
@Transactional
|
||||
public long saveDraft(long adminId, SmtpDraft draft) {
|
||||
validate(draft);
|
||||
@@ -52,6 +65,13 @@ public class SmtpConfigurationService {
|
||||
return configurations.save(configuration).getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a real probe using a draft and records success only after the adapter returns successfully.
|
||||
*
|
||||
* @param draftId draft revision to test
|
||||
* @param adminId active Admin performing the test
|
||||
* @param recipient Admin email receiving the test message
|
||||
*/
|
||||
public void testDraft(long draftId, long adminId, String recipient) {
|
||||
SmtpConfiguration draft = configurations.findById(draftId)
|
||||
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
|
||||
@@ -62,6 +82,12 @@ public class SmtpConfigurationService {
|
||||
configurations.save(draft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a previously tested draft under a pessimistic lock and retires the prior active revision.
|
||||
*
|
||||
* @param draftId tested draft revision
|
||||
* @param adminId active Admin authorizing activation
|
||||
*/
|
||||
@Transactional
|
||||
public void activate(long draftId, long adminId) {
|
||||
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
|
||||
@@ -73,16 +99,53 @@ public class SmtpConfigurationService {
|
||||
draft.activate(verifiedAdminId, now);
|
||||
}
|
||||
|
||||
/** @return {@code true} when a tested SMTP revision is currently active */
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasActiveConfiguration() {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the active SMTP connection for an immediate integration call.
|
||||
*
|
||||
* @return transient connection values, including a decrypted password when configured
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return mailDelivery.activeConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a plain-text message through the active SMTP revision.
|
||||
*
|
||||
* @param recipient destination email address
|
||||
* @param subject message subject
|
||||
* @param body message body
|
||||
*/
|
||||
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||
mailDelivery.send(recipient, subject, body);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
|
||||
/** External SMTP adapter boundary used by setup tests and application email delivery. */
|
||||
@FunctionalInterface
|
||||
public interface SmtpProbe {
|
||||
/**
|
||||
* Sends one immediate plain-text message using the supplied request-local connection values.
|
||||
*
|
||||
* @param connection complete SMTP connection material
|
||||
* @param recipient destination email address
|
||||
* @param subject message subject
|
||||
* @param body message body
|
||||
*/
|
||||
void send(SmtpConnection connection, String recipient, String subject, String body);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,22 @@
|
||||
<main>
|
||||
<p class="page-description">Use at least 12 characters. This activation link can be used once.</p>
|
||||
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
||||
<form class="form-grid auth-form" method="post" th:action="@{/activate}">
|
||||
<input name="token" type="hidden" th:value="${token}">
|
||||
<div class="field"><label class="field-label" for="password">Password</label><input class="control" id="password" name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
|
||||
<div class="field"><label class="field-label" for="confirmPassword">Confirm password</label><input class="control" id="confirmPassword" name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
|
||||
<form class="form-grid auth-form" method="post" th:action="@{/activate}" th:object="${activationForm}">
|
||||
<div class="alert alert-error" th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||
<p th:each="fieldError : ${#fields.globalErrors()}" th:text="${fieldError}"></p>
|
||||
</div>
|
||||
<input th:field="*{token}" type="hidden">
|
||||
<div class="field">
|
||||
<label class="field-label" for="password">Password</label>
|
||||
<input class="control" id="password" th:field="*{password}" type="password" minlength="12" maxlength="128" required autocomplete="new-password" th:attr="aria-invalid=${#fields.hasErrors('password')},aria-describedby=${#fields.hasErrors('password') ? 'activation-password-error' : null}">
|
||||
<p class="field-error" id="activation-password-error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="confirmPassword">Confirm password</label>
|
||||
<input class="control" id="confirmPassword" th:field="*{confirmPassword}" type="password" minlength="12" maxlength="128" required autocomplete="new-password" th:attr="aria-invalid=${#fields.hasErrors('confirmPassword') or #fields.hasErrors('passwordConfirmed')},aria-describedby=${#fields.hasErrors('confirmPassword') ? 'activation-confirm-password-error' : (#fields.hasErrors('passwordConfirmed') ? 'activation-password-confirmed-error' : null)}">
|
||||
<p class="field-error" id="activation-confirm-password-error" th:if="${#fields.hasErrors('confirmPassword')}" th:errors="*{confirmPassword}" role="alert"></p>
|
||||
<p class="field-error" id="activation-password-confirmed-error" th:if="${#fields.hasErrors('passwordConfirmed')}" th:errors="*{passwordConfirmed}" role="alert"></p>
|
||||
</div>
|
||||
<button class="button button-primary" type="submit">Activate account</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -10,27 +10,42 @@
|
||||
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
|
||||
<main>
|
||||
<p class="page-description">Create a role-specific account and send its one-time activation link.</p>
|
||||
<p class="alert alert-error" th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
|
||||
<p class="alert" th:if="${param.created}" role="status">Account created and activation email sent.</p>
|
||||
<p class="alert alert-error" th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p>
|
||||
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
||||
<form class="panel form-panel form-grid" method="post" th:action="@{/admin/accounts}">
|
||||
<div class="field"><label class="field-label" for="email">Email</label><input class="control" id="email" name="email" type="email" required autocomplete="off"></div>
|
||||
<div class="field"><label class="field-label" for="displayName">Display name</label><input class="control" id="displayName" name="displayName" required autocomplete="off"></div>
|
||||
<div class="field"><label class="field-label" for="role">Role</label>
|
||||
<select class="control" id="role" name="role" required>
|
||||
<form class="panel form-panel form-grid" method="post" th:action="@{/admin/accounts}" th:object="${accountForm}">
|
||||
<div class="alert alert-error" th:if="${#fields.hasAnyErrors()}" role="alert">
|
||||
<strong>Please correct the highlighted fields.</strong>
|
||||
<ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}"></li></ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="email">Email</label>
|
||||
<input class="control" id="email" th:field="*{email}" type="email" required autocomplete="off" th:attr="aria-invalid=${#fields.hasErrors('email')},aria-describedby=${#fields.hasErrors('email') ? 'account-email-error' : null}">
|
||||
<p class="field-error" id="account-email-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="displayName">Display name</label>
|
||||
<input class="control" id="displayName" th:field="*{displayName}" required autocomplete="off" th:attr="aria-invalid=${#fields.hasErrors('displayName')},aria-describedby=${#fields.hasErrors('displayName') ? 'account-display-name-error' : null}">
|
||||
<p class="field-error" id="account-display-name-error" th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="role">Role</label>
|
||||
<select class="control" id="role" th:field="*{role}" required th:attr="aria-invalid=${#fields.hasErrors('role')},aria-describedby=${#fields.hasErrors('role') ? 'account-role-error' : null}">
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="MENTOR">Mentor</option>
|
||||
<option value="INTERN">Intern</option>
|
||||
</select>
|
||||
<p class="field-error" id="account-role-error" th:if="${#fields.hasErrors('role')}" th:errors="*{role}" role="alert"></p>
|
||||
</div>
|
||||
<fieldset class="form-section">
|
||||
<fieldset class="form-section" th:attr="aria-describedby=${#fields.hasErrors('internDetailsValid') ? 'account-intern-details-error' : null}">
|
||||
<legend>Intern details</legend>
|
||||
<p class="field-help">Required only when the selected role is Intern.</p>
|
||||
<div class="form-grid form-grid-three">
|
||||
<div class="field"><label class="field-label" for="studentCode">Student code</label><input class="control" id="studentCode" name="studentCode" autocomplete="off"></div>
|
||||
<div class="field"><label class="field-label" for="internshipStart">Internship start</label><input class="control" id="internshipStart" name="internshipStart" type="date"></div>
|
||||
<div class="field"><label class="field-label" for="internshipEnd">Internship end</label><input class="control" id="internshipEnd" name="internshipEnd" type="date"></div>
|
||||
<div class="field"><label class="field-label" for="studentCode">Student code</label><input class="control" id="studentCode" th:field="*{studentCode}" autocomplete="off" th:attr="aria-invalid=${#fields.hasErrors('studentCode')},aria-describedby=${#fields.hasErrors('studentCode') ? 'account-student-code-error' : null}"><p class="field-error" id="account-student-code-error" th:if="${#fields.hasErrors('studentCode')}" th:errors="*{studentCode}" role="alert"></p></div>
|
||||
<div class="field"><label class="field-label" for="internshipStart">Internship start</label><input class="control" id="internshipStart" th:field="*{internshipStart}" type="date"></div>
|
||||
<div class="field"><label class="field-label" for="internshipEnd">Internship end</label><input class="control" id="internshipEnd" th:field="*{internshipEnd}" type="date"></div>
|
||||
</div>
|
||||
<p class="field-error" id="account-intern-details-error" th:if="${#fields.hasErrors('internDetailsValid')}" th:errors="*{internDetailsValid}" role="alert"></p>
|
||||
</fieldset>
|
||||
<div class="form-actions"><a class="button" th:href="@{/dashboard}">Cancel</a><button class="button button-primary" type="submit">Create account</button></div>
|
||||
</form>
|
||||
|
||||
@@ -8,10 +8,26 @@
|
||||
<main>
|
||||
<p class="page-description">Initialize this installation once. Later accounts are created by an active Admin.</p>
|
||||
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
||||
<form class="form-grid auth-form" method="post" th:action="@{/bootstrap}">
|
||||
<div class="field"><label class="field-label" for="email">Email</label><input class="control" id="email" name="email" type="email" required autocomplete="email" autofocus></div>
|
||||
<div class="field"><label class="field-label" for="displayName">Display name</label><input class="control" id="displayName" name="displayName" required autocomplete="name"></div>
|
||||
<div class="field"><label class="field-label" for="password">Password</label><input class="control" id="password" name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
|
||||
<form class="form-grid auth-form" method="post" th:action="@{/bootstrap}" th:object="${bootstrapForm}">
|
||||
<div class="alert alert-error" th:if="${#fields.hasAnyErrors()}" role="alert">
|
||||
<strong>Please correct the highlighted fields.</strong>
|
||||
<ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}"></li></ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="email">Email</label>
|
||||
<input class="control" id="email" th:field="*{email}" type="email" required autocomplete="email" autofocus th:attr="aria-invalid=${#fields.hasErrors('email')},aria-describedby=${#fields.hasErrors('email') ? 'bootstrap-email-error' : null}">
|
||||
<p class="field-error" id="bootstrap-email-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="displayName">Display name</label>
|
||||
<input class="control" id="displayName" th:field="*{displayName}" required autocomplete="name" th:attr="aria-invalid=${#fields.hasErrors('displayName')},aria-describedby=${#fields.hasErrors('displayName') ? 'bootstrap-display-name-error' : null}">
|
||||
<p class="field-error" id="bootstrap-display-name-error" th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="password">Password</label>
|
||||
<input class="control" id="password" th:field="*{password}" type="password" minlength="12" maxlength="128" required autocomplete="new-password" th:attr="aria-invalid=${#fields.hasErrors('password')},aria-describedby=${#fields.hasErrors('password') ? 'bootstrap-password-error' : null}">
|
||||
<p class="field-error" id="bootstrap-password-error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
|
||||
</div>
|
||||
<button class="button button-primary" type="submit">Create administrator</button>
|
||||
</form>
|
||||
</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>
|
||||
@@ -4,16 +4,42 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>SMTP configuration</h1>
|
||||
<form method="post" th:action="@{/admin/smtp/draft}">
|
||||
<label>Host <input name="host" required></label>
|
||||
<label>Port <input name="port" type="number" min="1" max="65535" required></label>
|
||||
<label>Security <select name="securityMode"><option>STARTTLS</option><option>TLS</option><option>NONE</option></select></label>
|
||||
<label>Username <input name="username" autocomplete="username"></label>
|
||||
<p th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
|
||||
<p th:if="${param.onboarding}">Configure SMTP now to enable account onboarding and recovery.</p>
|
||||
<p th:if="${smtpStatus.active}" role="status">SMTP is active.</p>
|
||||
<p th:if="${smtpStatus.draftId != null}" role="status">Draft saved.</p>
|
||||
<p th:if="${smtpStatus.tested}" role="status">Test passed.</p>
|
||||
<p th:if="${smtpActionError}" th:text="${smtpActionError}" role="alert"></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>From address <input name="fromAddress" type="email" required></label>
|
||||
<label>From name <input name="fromName" required></label>
|
||||
<p th:if="${#fields.hasErrors('authenticationComplete')}" th:errors="*{authenticationComplete}" role="alert"></p>
|
||||
<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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
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.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 SecurityResponseIntegrationTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrap;
|
||||
|
||||
@Test
|
||||
void assetsRemainPublicBeforeAndAfterBootstrap() throws Exception {
|
||||
mockMvc.perform(get("/assets/review-test.css").with(anonymous()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("asset")));
|
||||
|
||||
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
|
||||
mockMvc.perform(get("/assets/review-test.css").with(anonymous()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("asset")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticationAndActivationResponsesDoNotSendReferrers() throws Exception {
|
||||
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
|
||||
mockMvc.perform(get("/login"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Referrer-Policy", "no-referrer"));
|
||||
mockMvc.perform(get("/activate").param("token", "non-secret-test-fixture"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Referrer-Policy", "no-referrer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TimeConfigurationTest {
|
||||
@Test
|
||||
void utcInstantAtVietnamMidnightUsesTheNewLocalBusinessDate() {
|
||||
Clock applicationClock = new TimeConfiguration().applicationClock();
|
||||
Instant vietnamMidnight = Instant.parse("2026-08-14T17:00:00Z");
|
||||
|
||||
LocalDate businessDate = LocalDate.now(Clock.fixed(vietnamMidnight, applicationClock.getZone()));
|
||||
|
||||
assertThat(businessDate).isEqualTo(LocalDate.of(2026, 8, 15));
|
||||
}
|
||||
}
|
||||
+99
-1
@@ -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,104 @@ 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 duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail() throws Exception {
|
||||
mockMvc.perform(post("/admin/accounts")
|
||||
.with(user("admin@example.com").roles("ADMIN"))
|
||||
.with(csrf())
|
||||
.param("email", "first-intern@example.com")
|
||||
.param("displayName", "First Intern")
|
||||
.param("role", "INTERN")
|
||||
.param("studentCode", "STU-ROUND-2")
|
||||
.param("internshipStart", "2026-08-01")
|
||||
.param("internshipEnd", "2026-12-31"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
|
||||
mockMvc.perform(post("/admin/accounts")
|
||||
.with(user("admin@example.com").roles("ADMIN"))
|
||||
.with(csrf())
|
||||
.param("email", "second-intern@example.com")
|
||||
.param("displayName", "Second Intern")
|
||||
.param("role", "INTERN")
|
||||
.param("studentCode", " stu-round-2 ")
|
||||
.param("internshipStart", "2026-08-01")
|
||||
.param("internshipEnd", "2026-12-31"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("accounts/new"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString(
|
||||
"An Intern with this student code already exists")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("this email already exists"))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Second Intern")));
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
+140
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -33,11 +33,13 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class AccountActivationIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
@@ -138,6 +140,25 @@ class AccountActivationIntegrationTest {
|
||||
assertThat(summary.activeInternships()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void internshipCannotActivateBeforeItsBusinessStartDate() {
|
||||
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
long adminId = accounts.requireActiveAdminId("admin@example.com");
|
||||
activateSmtp(adminId);
|
||||
mail.messages.clear();
|
||||
|
||||
var creation = accounts.create(new CreateAccountCommand(
|
||||
"future-intern@example.com", "Future Intern", GlobalRole.INTERN, "STU-FUTURE",
|
||||
LocalDate.of(2026, 8, 15), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(accounts.activate(mail.onlyActivationToken(), "future secure password")).isTrue();
|
||||
|
||||
assertThatThrownBy(() -> accounts.activateInternship(creation.userId(), adminId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("start date");
|
||||
assertThat(internProfiles.findById(creation.userId()).orElseThrow().getInternshipStatus())
|
||||
.isEqualTo(InternshipStatus.NOT_STARTED);
|
||||
}
|
||||
|
||||
private void activateSmtp(long adminId) {
|
||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||
|
||||
+32
@@ -11,13 +11,19 @@ import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import com.lab.labtimesheet.LabtimesheetApplication;
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.context.annotation.Import;
|
||||
@@ -47,6 +53,9 @@ class BootstrapIntegrationTest {
|
||||
@Autowired
|
||||
private SystemStateRepository systemStates;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
|
||||
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
|
||||
@@ -87,6 +96,29 @@ class BootstrapIntegrationTest {
|
||||
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bootstrapRemainsClosedInAnIndependentApplicationContext() {
|
||||
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
|
||||
HikariDataSource currentDataSource = (HikariDataSource) dataSource;
|
||||
|
||||
try (var restarted = new SpringApplicationBuilder(LabtimesheetApplication.class)
|
||||
.profiles("test")
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties(
|
||||
"server.port=0",
|
||||
"spring.main.register-shutdown-hook=false",
|
||||
"spring.datasource.url=" + currentDataSource.getJdbcUrl(),
|
||||
"spring.datasource.username=" + currentDataSource.getUsername(),
|
||||
"spring.datasource.password=" + currentDataSource.getPassword())
|
||||
.run()) {
|
||||
BootstrapService restartedBootstrap = restarted.getBean(BootstrapService.class);
|
||||
assertThat(restartedBootstrap.isInitialized()).isTrue();
|
||||
assertThat(restartedBootstrap.bootstrap(
|
||||
"another@example.com", "Another", "correct horse battery staple"))
|
||||
.isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
|
||||
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
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.mail.MailSendException;
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft() 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("fromAddress", "notifications@example.com")
|
||||
.param("fromName", "Lab Timesheet"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
|
||||
String html = mockMvc.perform(get("/admin/smtp")
|
||||
.with(user("admin@example.com").roles("ADMIN")))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1");
|
||||
String rawDiagnostic = "AUTH rejected for smtp-secret-raw-diagnostic";
|
||||
probe.failure = new MailSendException(rawDiagnostic);
|
||||
|
||||
mockMvc.perform(post("/admin/smtp/test")
|
||||
.with(user("admin@example.com").roles("ADMIN"))
|
||||
.with(csrf())
|
||||
.param("draftId", draftId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("smtp/form"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString(
|
||||
"SMTP test failed. Verify the draft settings and server availability, then try again.")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString(rawDiagnostic))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("Activate SMTP"))));
|
||||
}
|
||||
|
||||
@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<>();
|
||||
private MailSendException failure;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
recipients.add(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
|
||||
class JavaMailSmtpProbeTest {
|
||||
@Test
|
||||
void appliesFiniteTimeoutsAndConfiguredFromName() throws Exception {
|
||||
var sender = new CapturingMailSender();
|
||||
var probe = new JavaMailSmtpProbe(() -> sender);
|
||||
var connection = new SmtpConnection(
|
||||
"smtp.example.com", 587, SecurityMode.STARTTLS, "user", "password",
|
||||
"noreply@example.com", "Lab Timesheet");
|
||||
|
||||
probe.send(connection, "admin@example.com", "Subject", "Body");
|
||||
|
||||
assertThat(sender.getJavaMailProperties())
|
||||
.containsEntry("mail.smtp.connectiontimeout", "5000")
|
||||
.containsEntry("mail.smtp.timeout", "5000")
|
||||
.containsEntry("mail.smtp.writetimeout", "5000");
|
||||
var from = (InternetAddress) sender.message.getFrom()[0];
|
||||
assertThat(from.getAddress()).isEqualTo("noreply@example.com");
|
||||
assertThat(from.getPersonal()).isEqualTo("Lab Timesheet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesFiniteTimeoutsToImplicitTlsTransport() {
|
||||
var sender = new CapturingMailSender();
|
||||
var probe = new JavaMailSmtpProbe(() -> sender);
|
||||
var connection = new SmtpConnection(
|
||||
"smtp.example.com", 465, SecurityMode.TLS, null, null,
|
||||
"noreply@example.com", "Lab Timesheet");
|
||||
|
||||
probe.send(connection, "admin@example.com", "Subject", "Body");
|
||||
|
||||
assertThat(sender.getProtocol()).isEqualTo("smtps");
|
||||
assertThat(sender.getJavaMailProperties())
|
||||
.containsEntry("mail.smtps.connectiontimeout", "5000")
|
||||
.containsEntry("mail.smtps.timeout", "5000")
|
||||
.containsEntry("mail.smtps.writetimeout", "5000");
|
||||
}
|
||||
|
||||
static final class CapturingMailSender extends JavaMailSenderImpl {
|
||||
private MimeMessage message;
|
||||
|
||||
@Override
|
||||
public void send(MimeMessage... mimeMessages) {
|
||||
assertThat(mimeMessages).hasSize(1);
|
||||
message = mimeMessages[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
asset
|
||||
Reference in New Issue
Block a user