refactor(platform): target Lombok boilerplate

This commit is contained in:
sechmachine
2026-08-15 11:39:53 +07:00
parent b9b150ff8c
commit 41448903aa
21 changed files with 190 additions and 240 deletions
@@ -0,0 +1,90 @@
# Test Evidence: Platform Lombok boilerplate retrofit
- **Test type:** Unit
- **Requirement IDs:** `Engineering policy — targeted Lombok retrofit`
- **Scenario IDs:** `Source-audit RED/GREEN`
- **Test class/method:** `N/A — reproducible source audit; behavior is covered by the affected suites below`
- **Implementation commit:** `pending`
## Protected behavior
Platform-owned Spring collaborators, request forms, and JPA entities must not retain eligible handwritten
dependency-assignment constructors, trivial accessors, or empty persistence constructors. The retrofit must preserve
constructor visibility, form normalization, entity encapsulation, defensive copies of credential/token bytes, and all
account, authentication, SMTP, and cross-feature behavior.
## Test method
The source audit searches only the 79 members classified as mechanical after reading every root/config,
`feature.account`, and `feature.integration` production type. It deliberately excludes normalized email/display-name
setters, defensive byte-array getters, domain constructors and factories, state transitions, the normalized
`AccountService` public-origin constructor, `SecretCipher` key construction, and the two-constructor JavaMail test
seam. No permanent annotation-presence test was added because annotations are an implementation detail; compilation
and production-shaped tests protect the real contracts.
## Hand-derived expected result
Before the retrofit the audit must find 79 eligible handwritten members and exit 1. After targeted Lombok generation,
the same audit must find none and exit 0, while the retained non-mechanical members remain explicit.
## RED
**Command**
```text
matches=$( { rg -n '^ (public )?(AccountController|BootstrapAccessFilter|BootstrapController|BootstrapService|DatabaseUserDetailsService|SmtpController|SmtpWarningAdvice|MailDeliveryService|SmtpConfigurationService)\(' src/main/java/com/lab/labtimesheet/feature/account src/main/java/com/lab/labtimesheet/feature/integration; rg -n '^ public (String getMasterKey|void setMasterKey)\(' src/main/java/com/lab/labtimesheet/config/SecurityProperties.java; rg -n '^ public (String get(Token|Password|ConfirmPassword)|void set(Token|Password|ConfirmPassword))\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java; rg -n '^ public (String get(Email|DisplayName|Password)|void setPassword)\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java; rg -n '^ public .+ (get(Email|DisplayName|Role|StudentCode|InternshipStart|InternshipEnd)|set(Role|StudentCode|InternshipStart|InternshipEnd))\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java; rg -n '^ public (Long getDraftId|void setDraftId)\(' src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java; rg -n '^ public .+ (get(Host|Port|SecurityMode|Username|Password|FromAddress|FromName)|set(Host|Port|SecurityMode|Username|Password|FromAddress|FromName))\(' src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java; rg -n '^ protected (AppUser|InternProfile|SystemState|UserActionToken|SmtpConfiguration)\(\)' src/main/java/com/lab/labtimesheet/feature/account/model/entity src/main/java/com/lab/labtimesheet/feature/integration/model/entity; rg -n '^ public .+ (get(Id|Email|DisplayName|PasswordHash|GlobalRole|AccountStatus|ActivatedAt|InternshipStatus|InternshipStartDate|InternshipEndDate|UserId|Purpose|ExpiresAt|UsedAt|InvalidatedAt|Status|Host|Port|SecurityMode|Username|SecretKeyVersion|FromAddress|FromName|TestedAt)|isInitialized)\(' src/main/java/com/lab/labtimesheet/feature/account/model/entity src/main/java/com/lab/labtimesheet/feature/integration/model/entity; } ); if [ -n "$matches" ]; then printf '%s\n' "$matches"; printf 'RED: eligible handwritten Lombok boilerplate remains (%s matches)\n' "$(printf '%s\n' "$matches" | wc -l | tr -d ' ')"; exit 1; fi; printf 'GREEN: no eligible handwritten Lombok boilerplate remains\n'
```
**Observed result**
```text
RED: eligible handwritten Lombok boilerplate remains (79 matches)
Process exited with code 1 because the confirmed mechanical members were still handwritten.
```
## GREEN
**Command**
```text
The exact RED source-audit command above was repeated without alteration.
```
**Observed result**
```text
GREEN: no eligible handwritten Lombok boilerplate remains
Process exited with code 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests compile
BUILD SUCCESS — 127 production source files compiled on Java 25.
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=LabtimesheetApplicationTests,LayerStructureTest,PlatformFoundationTest,TimeConfigurationTest,SecurityResponseIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SmtpIntegrationTest test
BUILD SUCCESS — Tests run: 29, Failures: 0, Errors: 0, Skipped: 0; PostgreSQL 18.4.
./mvnw test
BUILD SUCCESS — Tests run: 197, Failures: 0, Errors: 0, Skipped: 0; PostgreSQL 18.4.
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS — doclint reported no errors; Maven emitted 66 non-fatal missing-comment warnings across the integrated
tree, including generated default constructors/accessors.
git diff --check
No output; exit 0.
```
## External-test boundaries
This source audit does not prove Lombok internals or enforce a preferred annotation spelling. The compile and
PostgreSQL-backed affected/full suites prove generated constructor/accessor compatibility with Spring binding,
Security, JPA/Hibernate, Thymeleaf, and existing cross-feature consumers. No dependency, schema, migration, token,
credential, template, container, CI, or runtime configuration was changed.
@@ -2,21 +2,17 @@ package com.lab.labtimesheet.config;
import java.util.Base64;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Security material used to encrypt integration credentials at rest. */
@ConfigurationProperties("lab.security")
@Getter
@Setter
public class SecurityProperties {
private String masterKey;
public String getMasterKey() {
return masterKey;
}
public void setMasterKey(String masterKey) {
this.masterKey = masterKey;
}
/**
* Decodes and validates the configured AES-256 master key.
*
@@ -6,6 +6,8 @@ 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 jakarta.validation.Valid;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
@@ -20,13 +22,10 @@ import org.springframework.web.bind.annotation.PostMapping;
* constraints are mapped to their owning form fields without exposing persistence diagnostics.
*/
@Controller
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class AccountController {
private final AccountService accounts;
AccountController(AccountService accounts) {
this.accounts = accounts;
}
@GetMapping("/admin/accounts/new")
String newAccount(Model model) {
if (!model.containsAttribute("accountForm")) {
@@ -7,24 +7,17 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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.
*/
@RequiredArgsConstructor
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.
*
@@ -3,6 +3,8 @@ 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 lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -16,13 +18,10 @@ import org.springframework.web.server.ResponseStatusException;
/** Renders and processes the one-time first-Admin installation form. */
@Controller
@RequestMapping("/bootstrap")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class BootstrapController {
private final BootstrapService bootstrap;
BootstrapController(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@GetMapping
String form(Model model) {
requireOpen();
@@ -3,10 +3,14 @@ package com.lab.labtimesheet.feature.account.model.dto;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* Validated activation submission. Password fields remain request-local and are never repopulated by the view.
*/
@Getter
@Setter
public class ActivationForm {
@NotBlank(message = "This activation link is invalid or no longer usable")
private String token;
@@ -34,10 +38,4 @@ public class ActivationForm {
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; }
}
@@ -3,11 +3,14 @@ package com.lab.labtimesheet.feature.account.model.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* Validated browser input for creating the first administrator.
* The password is deliberately never copied into redirected state or repopulated after validation failure.
*/
@Getter
public class BootstrapForm {
@NotBlank(message = "Email is required")
@Email(message = "Enter a valid email address")
@@ -20,29 +23,15 @@ public class BootstrapForm {
@NotBlank(message = "Password is required")
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
@Setter
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;
}
}
@@ -8,9 +8,12 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import org.springframework.format.annotation.DateTimeFormat;
/** Validated, non-secret Admin input for creating an immutable-role account. */
@Getter
public class CreateAccountForm {
@NotBlank(message = "Email is required")
@Email(message = "Enter a valid email address")
@@ -22,15 +25,19 @@ public class CreateAccountForm {
private String displayName;
@NotNull(message = "Role is required")
@Setter
private GlobalRole role;
@Size(max = 64, message = "Student code must contain at most 64 characters")
@Setter
private String studentCode;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Setter
private LocalDate internshipStart;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Setter
private LocalDate internshipEnd;
/**
@@ -67,16 +74,6 @@ public class CreateAccountForm {
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; }
}
@@ -16,6 +16,9 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persistent global account with immutable role, authentication lifecycle, creator attribution, and optimistic
@@ -23,29 +26,37 @@ import jakarta.persistence.Version;
*/
@Entity
@Table(name = "app_users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter
private Long id;
@Column(nullable = false, length = 320)
@Getter
private String email;
@Column(name = "display_name", nullable = false, length = 120)
@Getter
private String displayName;
@Column(name = "password_hash", length = 255)
@Getter
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
@Getter
private GlobalRole globalRole;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", nullable = false, length = 32)
@Getter
private AccountStatus accountStatus;
@Column(name = "activated_at")
@Getter
private Instant activatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@@ -61,10 +72,6 @@ public class AppUser {
@Version
private long version;
/** Required by JPA; domain instances are created through named factories. */
protected AppUser() {
}
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
this.email = email;
@@ -124,31 +131,4 @@ public class AppUser {
updatedAt = now;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getPasswordHash() {
return passwordHash;
}
public GlobalRole getGlobalRole() {
return globalRole;
}
public AccountStatus getAccountStatus() {
return accountStatus;
}
public Instant getActivatedAt() {
return activatedAt;
}
}
@@ -11,6 +11,9 @@ import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persistent internship lifecycle and inclusive eligibility dates for an Intern account.
@@ -18,6 +21,7 @@ import jakarta.persistence.Version;
*/
@Entity
@Table(name = "intern_profiles")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class InternProfile {
@Id
@Column(name = "user_id")
@@ -33,13 +37,16 @@ public class InternProfile {
private String phone;
@Column(name = "internship_start_date", nullable = false)
@Getter
private LocalDate internshipStartDate;
@Column(name = "internship_end_date", nullable = false)
@Getter
private LocalDate internshipEndDate;
@Enumerated(EnumType.STRING)
@Column(name = "internship_status", nullable = false, length = 24)
@Getter
private InternshipStatus internshipStatus;
@Column(name = "activated_at")
@@ -60,10 +67,6 @@ public class InternProfile {
@Version
private long version;
/** Required by JPA; domain instances are created through {@link #notStarted}. */
protected InternProfile() {
}
private InternProfile(
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
this.userId = userId;
@@ -105,15 +108,4 @@ public class InternProfile {
updatedAt = now;
}
public InternshipStatus getInternshipStatus() {
return internshipStatus;
}
public LocalDate getInternshipStartDate() {
return internshipStartDate;
}
public LocalDate getInternshipEndDate() {
return internshipEndDate;
}
}
@@ -10,16 +10,21 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
@Entity
@Table(name = "system_state")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SystemState {
@Id
@Column(name = "singleton_id")
private short singletonId;
@Column(nullable = false)
@Getter
private boolean initialized;
@Column(name = "initialized_at")
@@ -38,14 +43,6 @@ public class SystemState {
@Version
private long version;
/** Required by JPA; Flyway creates the singleton row. */
protected SystemState() {
}
public boolean isInitialized() {
return initialized;
}
/**
* Marks the installation initialized and retains the first Admin attribution.
*
@@ -12,6 +12,9 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persistent one-time user-action token state. Only a defensive copy of the SHA-256 token hash is stored; raw
@@ -19,28 +22,35 @@ import jakarta.persistence.Table;
*/
@Entity
@Table(name = "user_action_tokens")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserActionToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter
private Long id;
@Column(name = "user_id", nullable = false)
@Getter
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
@Getter
private TokenPurpose purpose;
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
private byte[] tokenHash;
@Column(name = "expires_at", nullable = false)
@Getter
private Instant expiresAt;
@Column(name = "used_at")
@Getter
private Instant usedAt;
@Column(name = "invalidated_at")
@Getter
private Instant invalidatedAt;
@Column(name = "issued_by_user_id")
@@ -49,10 +59,6 @@ public class UserActionToken {
@Column(name = "created_at", nullable = false)
private Instant createdAt;
/** Required by JPA; domain instances are created through named factories. */
protected UserActionToken() {
}
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
this.userId = userId;
this.purpose = TokenPurpose.ACTIVATION;
@@ -115,18 +121,6 @@ public class UserActionToken {
}
}
public Long getId() {
return id;
}
public Long getUserId() {
return userId;
}
public TokenPurpose getPurpose() {
return purpose;
}
/**
* Returns a defensive copy of the persisted token hash.
*
@@ -136,15 +130,4 @@ public class UserActionToken {
return Arrays.copyOf(tokenHash, tokenHash.length);
}
public Instant getExpiresAt() {
return expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public Instant getInvalidatedAt() {
return invalidatedAt;
}
}
@@ -7,6 +7,8 @@ import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -16,20 +18,13 @@ import org.springframework.transaction.annotation.Transactional;
* Successful creation persists the first active Admin and initialization marker atomically.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class BootstrapService {
private final SystemStateRepository systemStates;
private final AppUserRepository users;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
Clock clock) {
this.systemStates = systemStates;
this.users = users;
this.passwords = passwords;
this.clock = clock;
}
/**
* Creates the first active Admin exactly once.
*
@@ -2,6 +2,8 @@ package com.lab.labtimesheet.feature.account.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
@@ -11,13 +13,10 @@ import org.springframework.transaction.annotation.Transactional;
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
/**
* Loads the normalized account and disables authentication unless its lifecycle state is active.
*
@@ -9,6 +9,8 @@ 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 lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.MailException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
*/
@Controller
@RequestMapping("/admin/smtp")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class SmtpController {
private static final String TEST_FAILURE_MESSAGE =
"SMTP test failed. Verify the draft settings and server availability, then try again.";
@@ -41,11 +44,6 @@ class SmtpController {
private final SmtpConfigurationService smtp;
private final AccountService accounts;
SmtpController(SmtpConfigurationService smtp, AccountService accounts) {
this.smtp = smtp;
this.accounts = accounts;
}
@GetMapping
String form(Model model) {
return renderForm(model, null);
@@ -1,6 +1,8 @@
package com.lab.labtimesheet.feature.integration.controller;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -9,13 +11,10 @@ import org.springframework.web.bind.annotation.ModelAttribute;
* configuration is active.
*/
@ControllerAdvice
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class SmtpWarningAdvice {
private final SmtpConfigurationService smtp;
SmtpWarningAdvice(SmtpConfigurationService smtp) {
this.smtp = smtp;
}
@ModelAttribute("smtpRestricted")
boolean smtpRestricted() {
return !smtp.hasActiveConfiguration();
@@ -2,18 +2,15 @@ package com.lab.labtimesheet.feature.integration.model.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.Setter;
/** Validated identifier submitted by the SMTP test and activation forms. */
@Getter
@Setter
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;
}
}
@@ -8,11 +8,15 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 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.
*/
@Getter
@Setter
public class SmtpForm {
@NotBlank(message = "Host is required")
@Size(max = 255, message = "Host must contain at most 255 characters")
@@ -96,18 +100,4 @@ public class SmtpForm {
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; }
}
@@ -15,6 +15,9 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Versioned SMTP configuration entity whose credentials remain AES-GCM encrypted at rest.
@@ -22,26 +25,33 @@ import jakarta.persistence.Version;
*/
@Entity
@Table(name = "smtp_configurations")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SmtpConfiguration {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
@Getter
private SmtpStatus status;
@Column(nullable = false, length = 255)
@Getter
private String host;
@Column(nullable = false)
@Getter
private int port;
@Enumerated(EnumType.STRING)
@Column(name = "security_mode", nullable = false, length = 16)
@Getter
private SecurityMode securityMode;
@Column(length = 320)
@Getter
private String username;
@Column(name = "password_ciphertext")
@@ -51,15 +61,19 @@ public class SmtpConfiguration {
private byte[] passwordNonce;
@Column(name = "secret_key_version")
@Getter
private Integer secretKeyVersion;
@Column(name = "from_address", nullable = false, length = 320)
@Getter
private String fromAddress;
@Column(name = "from_name", nullable = false, length = 120)
@Getter
private String fromName;
@Column(name = "tested_at")
@Getter
private Instant testedAt;
@Column(name = "tested_by_user_id")
@@ -89,10 +103,6 @@ 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.
*
@@ -191,30 +201,6 @@ public class SmtpConfiguration {
return value == null || value.isBlank() ? null : value.trim();
}
public Long getId() {
return id;
}
public SmtpStatus getStatus() {
return status;
}
public String getHost() {
return host;
}
public int getPort() {
return port;
}
public SecurityMode getSecurityMode() {
return securityMode;
}
public String getUsername() {
return username;
}
/** @return a defensive copy of encrypted password bytes, or {@code null} */
public byte[] getPasswordCiphertext() {
return passwordCiphertext == null ? null : passwordCiphertext.clone();
@@ -225,19 +211,4 @@ public class SmtpConfiguration {
return passwordNonce == null ? null : passwordNonce.clone();
}
public Integer getSecretKeyVersion() {
return secretKeyVersion;
}
public String getFromAddress() {
return fromAddress;
}
public String getFromName() {
return fromName;
}
public Instant getTestedAt() {
return testedAt;
}
}
@@ -4,6 +4,8 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -12,17 +14,12 @@ import org.springframework.transaction.annotation.Transactional;
* Stored credentials are decrypted only while constructing the immediate adapter call.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class MailDeliveryService {
private final SmtpConfigurationRepository configurations;
private final SecretCipher secrets;
private final SmtpProbe probe;
MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) {
this.configurations = configurations;
this.secrets = secrets;
this.probe = probe;
}
/**
* Reports whether workflows may emit required email.
*
@@ -11,6 +11,8 @@ 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 lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
@@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
* A changed draft loses prior test status, and an active revision is retired when its tested successor activates.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class SmtpConfigurationService {
private final SmtpConfigurationRepository configurations;
private final AccountService accounts;
@@ -30,18 +33,6 @@ public class SmtpConfigurationService {
private final Clock clock;
private final MailDeliveryService mailDelivery;
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock,
MailDeliveryService mailDelivery) {
this.configurations = configurations;
this.accounts = accounts;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
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.