diff --git a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java index 851a85c..cacb346 100644 --- a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java +++ b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java @@ -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); } diff --git a/src/main/java/com/lab/labtimesheet/ServletInitializer.java b/src/main/java/com/lab/labtimesheet/ServletInitializer.java index 816ecea..512f617 100644 --- a/src/main/java/com/lab/labtimesheet/ServletInitializer.java +++ b/src/main/java/com/lab/labtimesheet/ServletInitializer.java @@ -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); diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index e5f6f85..28f7f66 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -11,6 +11,10 @@ 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 diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java index 9b00897..7d19cb5 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java @@ -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"); diff --git a/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java index aba4a15..a942085 100644 --- a/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java @@ -5,6 +5,7 @@ import java.time.Clock; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** Provides the injectable UTC clock used for server-authoritative business time. */ @Configuration(proxyBeanMethods = false) class TimeConfiguration { @Bean diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java index 4518475..3039e37 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; +/** Handles Admin account creation and single-use account activation browser flows. */ @Controller class AccountController { private final AccountService accounts; diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java index b1d8e9c..487a54f 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AuthenticationController.java @@ -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") diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java index c9472bc..1360e56 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java @@ -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 { diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java index e7bd134..1fc3a1a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.server.ResponseStatusException; +/** Renders and processes the one-time first-Admin installation form. */ @Controller @RequestMapping("/bootstrap") class BootstrapController { diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java index 053a38f..4fd9ee5 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java @@ -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("/") diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java index a62ff21..558f455 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.account.model; +/** Durable authentication lifecycle of a global account. */ public enum AccountStatus { PENDING_ACTIVATION, ACTIVE, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java index defdc1c..9d331cf 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java @@ -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, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java index fa86482..3c2df23 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java @@ -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, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java b/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java index a1a9f5f..4ff0628 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/TokenPurpose.java @@ -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 diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java index 1797583..babe402 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountCreation.java @@ -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) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java index 66110f5..9976162 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java @@ -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, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java index 946f03e..4a89886 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountSummary.java @@ -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) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java index 8e0b384..18a0cba 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountCommand.java @@ -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, diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java index 656dd47..26e0c54 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java @@ -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"); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java index c2011dc..4d4452c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java @@ -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"); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java index e1ca36b..d01c219 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java @@ -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"); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java index 3e4d6fd..98a6fd2 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/UserActionToken.java @@ -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); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java index 7d13a81..3b5bca4 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java @@ -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 { + /** + * 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 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 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); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java index 25aeffc..781e259 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -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 { + /** 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 findForUpdateByUserId(@Param("userId") Long userId); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java index 294ee7c..d5539ff 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java @@ -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 { + /** + * 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 findSingletonForUpdate(); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java index ab932bb..1b473de 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/UserActionTokenRepository.java @@ -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 { + /** + * 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 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 findForUpdateById(@Param("id") Long id); diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java index 4a806bc..81b7ba8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -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); @@ -140,6 +161,11 @@ public class AccountService { 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( @@ -148,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) @@ -170,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) { @@ -184,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)) { @@ -192,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)) @@ -199,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) diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java index 288ca68..9ea29aa 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java @@ -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 diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java index 0a78533..d3633af 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java @@ -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 { diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index c802868..d44a800 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -17,6 +17,10 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +/** + * 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. + */ @Controller @RequestMapping("/admin/smtp") class SmtpController { @@ -131,7 +135,7 @@ class SmtpController { } private String renderActionError(Model model, BindingResult bindingResult) { - model.addAttribute(BindingResult.MODEL_KEY_PREFIX + "smtpAction", bindingResult); + model.addAttribute("smtpActionError", bindingResult.getAllErrors().getFirst().getDefaultMessage()); return renderForm(model, null); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java index 0356e10..97ba27a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java @@ -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, diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java index 104b501..6902619 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java @@ -1,5 +1,6 @@ package com.lab.labtimesheet.feature.integration.model; +/** Lifecycle state of a versioned SMTP configuration. */ public enum SmtpStatus { DRAFT, ACTIVE, diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java index 48cf6d6..238d986 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java @@ -1,16 +1,25 @@ 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) { 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(); diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java index e5659cc..27822ad 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java @@ -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) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java index 3df5bb5..efb765c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java @@ -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) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java index 738ea1f..989e81c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java @@ -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(); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java index 9805e59..fba1872 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java @@ -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 { + /** Finds the single revision in a given lifecycle state. */ Optional 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 findWithLockByIdAndStatus(Long id, SmtpStatus status); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java index 36a0ac7..7075fd0 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/MailDeliveryService.java @@ -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) diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java index 033d22f..e4ab18d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java @@ -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; diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java index c46acb8..60fabd1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java @@ -16,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; @@ -38,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); @@ -53,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) @@ -63,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) @@ -74,6 +99,7 @@ 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(); @@ -103,11 +129,23 @@ public class SmtpConfigurationService { 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); } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java index c96308e..7a2874d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java @@ -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); } diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html index d34459f..6b25946 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -9,6 +9,7 @@

SMTP is active.

Draft saved.

Test passed.

+

diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index d2edb50..f17b3b0 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -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"); diff --git a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java index 53e57f3..abd88a2 100644 --- a/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/controller/SmtpOnboardingWebIntegrationTest.java @@ -141,6 +141,36 @@ class SmtpOnboardingWebIntegrationTest { .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"); + probe.failureMessage = "Connection refused by the configured SMTP server"; + + 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( + "Connection refused by the configured SMTP server"))) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("Activate SMTP")))); + } + @TestConfiguration(proxyBeanMethods = false) static class ProbeConfiguration { @Bean @@ -152,9 +182,13 @@ class SmtpOnboardingWebIntegrationTest { static final class RecordingProbe implements SmtpProbe { private final List recipients = new ArrayList<>(); + private String failureMessage; @Override public void send(SmtpConnection connection, String recipient, String subject, String body) { + if (failureMessage != null) { + throw new IllegalStateException(failureMessage); + } recipients.add(recipient); } }