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:
sechmachine
2026-08-15 03:32:40 +07:00
81 changed files with 2595 additions and 130 deletions
@@ -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);
}
}
@@ -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;
}
}
@@ -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")
@@ -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");
}
}
@@ -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;
}
}
@@ -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,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);
@@ -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();
@@ -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
@@ -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 {
@@ -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) {
@@ -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) {
}
@@ -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();
}
@@ -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);
}
@@ -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);
}
}
@@ -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;
@@ -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>
+25 -10
View File
@@ -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>
+33 -7
View File
@@ -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));
}
}
@@ -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
@@ -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));
}
}
@@ -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"));
@@ -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");
@@ -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);
}
}
}
@@ -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