address platform review findings and document APIs
This commit is contained in:
@@ -6,10 +6,16 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|||||||
|
|
||||||
import com.lab.labtimesheet.config.SecurityProperties;
|
import com.lab.labtimesheet.config.SecurityProperties;
|
||||||
|
|
||||||
|
/** Application entry point and root component-scan boundary for Lab Timesheet. */
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties(SecurityProperties.class)
|
@EnableConfigurationProperties(SecurityProperties.class)
|
||||||
public class LabtimesheetApplication {
|
public class LabtimesheetApplication {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the standalone Spring Boot process.
|
||||||
|
*
|
||||||
|
* @param args command-line arguments forwarded to Spring Boot
|
||||||
|
*/
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(LabtimesheetApplication.class, args);
|
SpringApplication.run(LabtimesheetApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ package com.lab.labtimesheet;
|
|||||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||||
|
|
||||||
|
/** Configures the application when deployed as a traditional servlet-container WAR. */
|
||||||
public class ServletInitializer extends SpringBootServletInitializer {
|
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
|
@Override
|
||||||
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
||||||
return application.sources(LabtimesheetApplication.class);
|
return application.sources(LabtimesheetApplication.class);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import org.springframework.security.web.SecurityFilterChain;
|
|||||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||||
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
|
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)
|
@Configuration(proxyBeanMethods = false)
|
||||||
class SecurityConfiguration {
|
class SecurityConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import java.util.Base64;
|
|||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/** Security material used to encrypt integration credentials at rest. */
|
||||||
@ConfigurationProperties("lab.security")
|
@ConfigurationProperties("lab.security")
|
||||||
public class SecurityProperties {
|
public class SecurityProperties {
|
||||||
private String masterKey;
|
private String masterKey;
|
||||||
@@ -16,6 +17,12 @@ public class SecurityProperties {
|
|||||||
this.masterKey = masterKey;
|
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() {
|
public byte[] decodedMasterKey() {
|
||||||
if (masterKey == null || masterKey.isBlank()) {
|
if (masterKey == null || masterKey.isBlank()) {
|
||||||
throw new IllegalStateException("lab.security.master-key is required");
|
throw new IllegalStateException("lab.security.master-key is required");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.time.Clock;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/** Provides the injectable UTC clock used for server-authoritative business time. */
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
class TimeConfiguration {
|
class TimeConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
||||||
|
/** Handles Admin account creation and single-use account activation browser flows. */
|
||||||
@Controller
|
@Controller
|
||||||
class AccountController {
|
class AccountController {
|
||||||
private final AccountService accounts;
|
private final AccountService accounts;
|
||||||
|
|||||||
+1
@@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/** Renders the project-owned form-login page used by Spring Security. */
|
||||||
@Controller
|
@Controller
|
||||||
class AuthenticationController {
|
class AuthenticationController {
|
||||||
@GetMapping("/login")
|
@GetMapping("/login")
|
||||||
|
|||||||
+18
@@ -9,13 +9,31 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
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 {
|
public class BootstrapAccessFilter extends OncePerRequestFilter {
|
||||||
private final BootstrapService bootstrap;
|
private final BootstrapService bootstrap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the pre-bootstrap access guard.
|
||||||
|
*
|
||||||
|
* @param bootstrap durable installation-state service
|
||||||
|
*/
|
||||||
public BootstrapAccessFilter(BootstrapService bootstrap) {
|
public BootstrapAccessFilter(BootstrapService bootstrap) {
|
||||||
this.bootstrap = 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
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/** Renders and processes the one-time first-Admin installation form. */
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/bootstrap")
|
@RequestMapping("/bootstrap")
|
||||||
class BootstrapController {
|
class BootstrapController {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/** Maps the authenticated application root to the shared role-aware dashboard. */
|
||||||
@Controller
|
@Controller
|
||||||
class HomeController {
|
class HomeController {
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Durable authentication lifecycle of a global account. */
|
||||||
public enum AccountStatus {
|
public enum AccountStatus {
|
||||||
PENDING_ACTIVATION,
|
PENDING_ACTIVATION,
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Immutable system-wide role assigned when an account is created. */
|
||||||
public enum GlobalRole {
|
public enum GlobalRole {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
MENTOR,
|
MENTOR,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Durable lifecycle of an Intern's internship independently of account activation. */
|
||||||
public enum InternshipStatus {
|
public enum InternshipStatus {
|
||||||
NOT_STARTED,
|
NOT_STARTED,
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Purpose discriminator preventing one bearer-token class from serving another workflow. */
|
||||||
public enum TokenPurpose {
|
public enum TokenPurpose {
|
||||||
ACTIVATION,
|
ACTIVATION,
|
||||||
PASSWORD_RESET
|
PASSWORD_RESET
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model.dto;
|
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) {
|
public record AccountCreation(long userId, boolean deliverySucceeded) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ package com.lab.labtimesheet.feature.account.model.dto;
|
|||||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
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(
|
public record AccountIdentity(
|
||||||
long id,
|
long id,
|
||||||
String email,
|
String email,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model.dto;
|
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) {
|
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -4,6 +4,16 @@ import java.time.LocalDate;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
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(
|
public record CreateAccountCommand(
|
||||||
String email,
|
String email,
|
||||||
String displayName,
|
String displayName,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import jakarta.persistence.ManyToOne;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
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
|
@Entity
|
||||||
@Table(name = "app_users")
|
@Table(name = "app_users")
|
||||||
public class AppUser {
|
public class AppUser {
|
||||||
@@ -57,6 +61,7 @@ public class AppUser {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; domain instances are created through named factories. */
|
||||||
protected AppUser() {
|
protected AppUser() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +78,42 @@ public class AppUser {
|
|||||||
this.updatedAt = now;
|
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) {
|
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);
|
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(
|
public static AppUser pending(
|
||||||
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
||||||
return new AppUser(
|
return new AppUser(
|
||||||
email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now);
|
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) {
|
public void activate(String encodedPassword, Instant now) {
|
||||||
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
||||||
throw new IllegalStateException("Only a pending account can activate");
|
throw new IllegalStateException("Only a pending account can activate");
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
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
|
@Entity
|
||||||
@Table(name = "intern_profiles")
|
@Table(name = "intern_profiles")
|
||||||
public class InternProfile {
|
public class InternProfile {
|
||||||
@@ -56,6 +60,7 @@ public class InternProfile {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; domain instances are created through {@link #notStarted}. */
|
||||||
protected InternProfile() {
|
protected InternProfile() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +75,27 @@ public class InternProfile {
|
|||||||
this.updatedAt = now;
|
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(
|
public static InternProfile notStarted(
|
||||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||||
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, 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) {
|
public void activate(Instant now) {
|
||||||
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
||||||
throw new IllegalStateException("Only a not-started internship can activate");
|
throw new IllegalStateException("Only a not-started internship can activate");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import jakarta.persistence.ManyToOne;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
import jakarta.persistence.Version;
|
||||||
|
|
||||||
|
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "system_state")
|
@Table(name = "system_state")
|
||||||
public class SystemState {
|
public class SystemState {
|
||||||
@@ -37,6 +38,7 @@ public class SystemState {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; Flyway creates the singleton row. */
|
||||||
protected SystemState() {
|
protected SystemState() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +46,13 @@ public class SystemState {
|
|||||||
return initialized;
|
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) {
|
public void initialize(AppUser admin, Instant now) {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
throw new IllegalStateException("Bootstrap is already complete");
|
throw new IllegalStateException("Bootstrap is already complete");
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import jakarta.persistence.GenerationType;
|
|||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Table;
|
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
|
@Entity
|
||||||
@Table(name = "user_action_tokens")
|
@Table(name = "user_action_tokens")
|
||||||
public class UserActionToken {
|
public class UserActionToken {
|
||||||
@@ -45,6 +49,7 @@ public class UserActionToken {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
|
/** Required by JPA; domain instances are created through named factories. */
|
||||||
protected UserActionToken() {
|
protected UserActionToken() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,15 +62,37 @@ public class UserActionToken {
|
|||||||
this.createdAt = now;
|
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(
|
public static UserActionToken activation(
|
||||||
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||||
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, 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) {
|
public boolean isUsableAt(Instant now) {
|
||||||
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
|
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) {
|
public void markUsed(Instant now) {
|
||||||
if (!isUsableAt(now)) {
|
if (!isUsableAt(now)) {
|
||||||
throw new IllegalStateException("Activation token is not usable");
|
throw new IllegalStateException("Activation token is not usable");
|
||||||
@@ -73,6 +100,12 @@ public class UserActionToken {
|
|||||||
usedAt = now;
|
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) {
|
public void invalidate(Instant now) {
|
||||||
if (usedAt != null) {
|
if (usedAt != null) {
|
||||||
throw new IllegalStateException("A used token cannot be invalidated");
|
throw new IllegalStateException("A used token cannot be invalidated");
|
||||||
@@ -94,6 +127,11 @@ public class UserActionToken {
|
|||||||
return purpose;
|
return purpose;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a defensive copy of the persisted token hash.
|
||||||
|
*
|
||||||
|
* @return copied SHA-256 hash bytes
|
||||||
|
*/
|
||||||
public byte[] getTokenHash() {
|
public byte[] getTokenHash() {
|
||||||
return Arrays.copyOf(tokenHash, tokenHash.length);
|
return Arrays.copyOf(tokenHash, tokenHash.length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,30 @@ import org.springframework.data.jpa.repository.Lock;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/** Account-feature persistence boundary for global users. */
|
||||||
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
||||||
|
/**
|
||||||
|
* Finds an account by its canonical lower-case, trimmed email.
|
||||||
|
*
|
||||||
|
* @param email normalized email
|
||||||
|
* @return matching account, if present
|
||||||
|
*/
|
||||||
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
||||||
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks an account row for a lifecycle mutation until the current transaction completes.
|
||||||
|
*
|
||||||
|
* @param id account identifier
|
||||||
|
* @return locked account, if present
|
||||||
|
*/
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select u from AppUser u where u.id = :id")
|
@Query("select u from AppUser u where u.id = :id")
|
||||||
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/** Counts accounts matching an immutable role and lifecycle state. */
|
||||||
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
||||||
|
|
||||||
|
/** Counts accounts in a lifecycle state. */
|
||||||
long countByAccountStatus(AccountStatus status);
|
long countByAccountStatus(AccountStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -10,14 +10,24 @@ import org.springframework.data.jpa.repository.Lock;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/** Account-feature persistence boundary for Intern lifecycle and eligibility. */
|
||||||
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
||||||
|
/** Returns whether an Intern profile has the requested lifecycle state. */
|
||||||
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
||||||
|
|
||||||
|
/** Returns whether an Intern is in the requested state throughout the supplied inclusive date point. */
|
||||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||||
|
|
||||||
|
/** Counts Intern profiles in a lifecycle state. */
|
||||||
long countByInternshipStatus(InternshipStatus status);
|
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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select p from InternProfile p where p.userId = :userId")
|
@Query("select p from InternProfile p where p.userId = :userId")
|
||||||
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
||||||
|
|||||||
+6
@@ -8,7 +8,13 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
/** Persistence boundary for the single durable installation-state row. */
|
||||||
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
|
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
|
||||||
|
/**
|
||||||
|
* Locks the singleton row so concurrent bootstrap attempts cannot both create a first Admin.
|
||||||
|
*
|
||||||
|
* @return locked installation state
|
||||||
|
*/
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select s from SystemState s where s.singletonId = 1")
|
@Query("select s from SystemState s where s.singletonId = 1")
|
||||||
Optional<SystemState> findSingletonForUpdate();
|
Optional<SystemState> findSingletonForUpdate();
|
||||||
|
|||||||
+14
@@ -10,12 +10,26 @@ import org.springframework.data.jpa.repository.Lock;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/** Persistence boundary for hashed, one-time account-action tokens. */
|
||||||
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
||||||
|
/**
|
||||||
|
* Locks a token selected by hash and purpose for atomic single-use consumption.
|
||||||
|
*
|
||||||
|
* @param hash SHA-256 hash of the supplied raw bearer token
|
||||||
|
* @param purpose expected workflow purpose
|
||||||
|
* @return locked matching token, if present
|
||||||
|
*/
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
||||||
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
||||||
@Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose);
|
@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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select t from UserActionToken t where t.id = :id")
|
@Query("select t from UserActionToken t where t.id = :id")
|
||||||
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
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
|
@Service
|
||||||
public class AccountService {
|
public class AccountService {
|
||||||
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
||||||
@@ -63,6 +67,15 @@ public class AccountService {
|
|||||||
this.publicOrigin = normalizeOrigin(publicOrigin);
|
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) {
|
public AccountCreation create(CreateAccountCommand command, long adminId) {
|
||||||
ValidatedAccount account = validate(command);
|
ValidatedAccount account = validate(command);
|
||||||
if (!mailDelivery.isAvailable()) {
|
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
|
@Transactional
|
||||||
public boolean activate(String rawToken, String password) {
|
public boolean activate(String rawToken, String password) {
|
||||||
BootstrapService.requirePassword(password);
|
BootstrapService.requirePassword(password);
|
||||||
@@ -140,6 +161,11 @@ public class AccountService {
|
|||||||
profile.activate(clock.instant());
|
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)
|
@Transactional(readOnly = true)
|
||||||
public AccountSummary summary() {
|
public AccountSummary summary() {
|
||||||
return new AccountSummary(
|
return new AccountSummary(
|
||||||
@@ -148,18 +174,38 @@ public class AccountService {
|
|||||||
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
|
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)
|
@Transactional(readOnly = true)
|
||||||
public AccountIdentity requireIdentityById(long userId) {
|
public AccountIdentity requireIdentityById(long userId) {
|
||||||
return users.findById(userId).map(AccountService::identity)
|
return users.findById(userId).map(AccountService::identity)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public AccountIdentity requireIdentityByEmail(String email) {
|
public AccountIdentity requireIdentityByEmail(String email) {
|
||||||
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
|
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public boolean isEligibleIntern(long userId) {
|
public boolean isEligibleIntern(long userId) {
|
||||||
return users.findById(userId)
|
return users.findById(userId)
|
||||||
@@ -170,6 +216,14 @@ public class AccountService {
|
|||||||
.isPresent();
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public boolean isEligibleIntern(long userId, LocalDate workDate) {
|
public boolean isEligibleIntern(long userId, LocalDate workDate) {
|
||||||
if (workDate == null) {
|
if (workDate == null) {
|
||||||
@@ -184,6 +238,13 @@ public class AccountService {
|
|||||||
.isPresent();
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public AccountIdentity requireEligibleIntern(long userId) {
|
public AccountIdentity requireEligibleIntern(long userId) {
|
||||||
if (!isEligibleIntern(userId)) {
|
if (!isEligibleIntern(userId)) {
|
||||||
@@ -192,6 +253,13 @@ public class AccountService {
|
|||||||
return requireIdentityById(userId);
|
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)
|
@Transactional(readOnly = true)
|
||||||
public long requireActiveAdminId(String email) {
|
public long requireActiveAdminId(String email) {
|
||||||
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
|
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
|
||||||
@@ -199,6 +267,13 @@ public class AccountService {
|
|||||||
return requireActiveAdmin(user);
|
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)
|
@Transactional(readOnly = true)
|
||||||
public long requireActiveAdminId(long userId) {
|
public long requireActiveAdminId(long userId) {
|
||||||
AppUser user = users.findById(userId)
|
AppUser user = users.findById(userId)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class BootstrapService {
|
public class BootstrapService {
|
||||||
private final SystemStateRepository systemStates;
|
private final SystemStateRepository systemStates;
|
||||||
@@ -26,6 +30,14 @@ public class BootstrapService {
|
|||||||
this.clock = clock;
|
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
|
@Transactional
|
||||||
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
|
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
|
||||||
String normalizedEmail = normalizeEmail(email);
|
String normalizedEmail = normalizeEmail(email);
|
||||||
@@ -44,15 +56,32 @@ public class BootstrapService {
|
|||||||
return BootstrapOutcome.CREATED;
|
return BootstrapOutcome.CREATED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the durable installation state.
|
||||||
|
*
|
||||||
|
* @return {@code true} after the first Admin has been committed
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean isInitialized() {
|
public boolean isInitialized() {
|
||||||
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
|
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) {
|
public static String normalizeEmail(String email) {
|
||||||
return requireText(email, "Email").toLowerCase(Locale.ROOT);
|
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) {
|
public static void requirePassword(String password) {
|
||||||
if (password == null || password.length() < 12 || password.length() > 128) {
|
if (password == null || password.length() < 12 || password.length() > 128) {
|
||||||
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
|
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
|
||||||
@@ -66,6 +95,7 @@ public class BootstrapService {
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of attempting the single allowed installation bootstrap. */
|
||||||
public enum BootstrapOutcome {
|
public enum BootstrapOutcome {
|
||||||
CREATED,
|
CREATED,
|
||||||
ALREADY_INITIALIZED
|
ALREADY_INITIALIZED
|
||||||
|
|||||||
+8
@@ -9,6 +9,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
|
||||||
@Service
|
@Service
|
||||||
class DatabaseUserDetailsService implements UserDetailsService {
|
class DatabaseUserDetailsService implements UserDetailsService {
|
||||||
private final AppUserRepository users;
|
private final AppUserRepository users;
|
||||||
@@ -17,6 +18,13 @@ class DatabaseUserDetailsService implements UserDetailsService {
|
|||||||
this.users = users;
|
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
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
|||||||
+5
-1
@@ -17,6 +17,10 @@ import org.springframework.web.bind.annotation.ModelAttribute;
|
|||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
@Controller
|
||||||
@RequestMapping("/admin/smtp")
|
@RequestMapping("/admin/smtp")
|
||||||
class SmtpController {
|
class SmtpController {
|
||||||
@@ -131,7 +135,7 @@ class SmtpController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String renderActionError(Model model, BindingResult bindingResult) {
|
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);
|
return renderForm(model, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.model;
|
package com.lab.labtimesheet.feature.integration.model;
|
||||||
|
|
||||||
|
/** Transport security mode used when opening an SMTP connection. */
|
||||||
public enum SecurityMode {
|
public enum SecurityMode {
|
||||||
NONE,
|
NONE,
|
||||||
STARTTLS,
|
STARTTLS,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.model;
|
package com.lab.labtimesheet.feature.integration.model;
|
||||||
|
|
||||||
|
/** Lifecycle state of a versioned SMTP configuration. */
|
||||||
public enum SmtpStatus {
|
public enum SmtpStatus {
|
||||||
DRAFT,
|
DRAFT,
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
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 record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
|
||||||
public EncryptedSecret {
|
public EncryptedSecret {
|
||||||
ciphertext = ciphertext.clone();
|
ciphertext = ciphertext.clone();
|
||||||
nonce = nonce.clone();
|
nonce = nonce.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of the encrypted credential bytes */
|
||||||
@Override
|
@Override
|
||||||
public byte[] ciphertext() {
|
public byte[] ciphertext() {
|
||||||
return ciphertext.clone();
|
return ciphertext.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of the AES-GCM nonce */
|
||||||
@Override
|
@Override
|
||||||
public byte[] nonce() {
|
public byte[] nonce() {
|
||||||
return nonce.clone();
|
return nonce.clone();
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
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,
|
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
|
||||||
String fromAddress, String fromName) {
|
String fromAddress, String fromName) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
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,
|
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
|
||||||
String fromAddress, String fromName) {
|
String fromAddress, String fromName) {
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -16,6 +16,10 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
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
|
@Entity
|
||||||
@Table(name = "smtp_configurations")
|
@Table(name = "smtp_configurations")
|
||||||
public class SmtpConfiguration {
|
public class SmtpConfiguration {
|
||||||
@@ -85,9 +89,19 @@ public class SmtpConfiguration {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; revisions are created through {@link #draft}. */
|
||||||
protected SmtpConfiguration() {
|
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) {
|
public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) {
|
||||||
SmtpConfiguration configuration = new SmtpConfiguration();
|
SmtpConfiguration configuration = new SmtpConfiguration();
|
||||||
configuration.status = SmtpStatus.DRAFT;
|
configuration.status = SmtpStatus.DRAFT;
|
||||||
@@ -97,6 +111,14 @@ public class SmtpConfiguration {
|
|||||||
return configuration;
|
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) {
|
public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) {
|
||||||
if (status != SmtpStatus.DRAFT) {
|
if (status != SmtpStatus.DRAFT) {
|
||||||
throw new IllegalStateException("Only an SMTP draft can be edited");
|
throw new IllegalStateException("Only an SMTP draft can be edited");
|
||||||
@@ -115,6 +137,13 @@ public class SmtpConfiguration {
|
|||||||
updatedAt = now;
|
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) {
|
public void markTested(long adminId, Instant now) {
|
||||||
if (status != SmtpStatus.DRAFT) {
|
if (status != SmtpStatus.DRAFT) {
|
||||||
throw new IllegalStateException("SMTP draft is no longer available");
|
throw new IllegalStateException("SMTP draft is no longer available");
|
||||||
@@ -124,6 +153,13 @@ public class SmtpConfiguration {
|
|||||||
updatedAt = now;
|
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) {
|
public void activate(long adminId, Instant now) {
|
||||||
if (status != SmtpStatus.DRAFT || testedAt == null) {
|
if (status != SmtpStatus.DRAFT || testedAt == null) {
|
||||||
throw new IllegalStateException("SMTP draft must pass a test before activation");
|
throw new IllegalStateException("SMTP draft must pass a test before activation");
|
||||||
@@ -134,6 +170,13 @@ public class SmtpConfiguration {
|
|||||||
updatedAt = now;
|
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) {
|
public void retire(long adminId, Instant now) {
|
||||||
if (status != SmtpStatus.ACTIVE) {
|
if (status != SmtpStatus.ACTIVE) {
|
||||||
throw new IllegalStateException("Only active SMTP can be retired");
|
throw new IllegalStateException("Only active SMTP can be retired");
|
||||||
@@ -172,10 +215,12 @@ public class SmtpConfiguration {
|
|||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of encrypted password bytes, or {@code null} */
|
||||||
public byte[] getPasswordCiphertext() {
|
public byte[] getPasswordCiphertext() {
|
||||||
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of the AES-GCM nonce, or {@code null} */
|
||||||
public byte[] getPasswordNonce() {
|
public byte[] getPasswordNonce() {
|
||||||
return passwordNonce == null ? null : passwordNonce.clone();
|
return passwordNonce == null ? null : passwordNonce.clone();
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -8,11 +8,21 @@ import jakarta.persistence.LockModeType;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
|
||||||
|
/** Integration-feature persistence boundary for retained SMTP revisions. */
|
||||||
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
|
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
|
||||||
|
/** Finds the single revision in a given lifecycle state. */
|
||||||
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
|
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
|
||||||
|
|
||||||
|
/** Returns whether a revision exists in a lifecycle state. */
|
||||||
boolean existsByStatus(SmtpStatus status);
|
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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
|
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -7,6 +7,10 @@ import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepo
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class MailDeliveryService {
|
public class MailDeliveryService {
|
||||||
private final SmtpConfigurationRepository configurations;
|
private final SmtpConfigurationRepository configurations;
|
||||||
@@ -19,15 +23,34 @@ public class MailDeliveryService {
|
|||||||
this.probe = probe;
|
this.probe = probe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether workflows may emit required email.
|
||||||
|
*
|
||||||
|
* @return {@code true} when an active tested SMTP revision exists
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean isAvailable() {
|
public boolean isAvailable() {
|
||||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
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) {
|
public void send(String recipient, String subject, String body) {
|
||||||
probe.send(activeConnection(), recipient, subject, 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)
|
@Transactional(readOnly = true)
|
||||||
public SmtpConnection activeConnection() {
|
public SmtpConnection activeConnection() {
|
||||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import javax.crypto.spec.GCMParameterSpec;
|
|||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Encrypts SMTP credentials with AES-256-GCM using a fresh nonce per stored revision. */
|
||||||
@Component
|
@Component
|
||||||
public class SecretCipher {
|
public class SecretCipher {
|
||||||
private static final int NONCE_BYTES = 12;
|
private static final int NONCE_BYTES = 12;
|
||||||
|
|||||||
+38
@@ -16,6 +16,10 @@ import org.springframework.core.env.Profiles;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class SmtpConfigurationService {
|
public class SmtpConfigurationService {
|
||||||
private final SmtpConfigurationRepository configurations;
|
private final SmtpConfigurationRepository configurations;
|
||||||
@@ -38,6 +42,14 @@ public class SmtpConfigurationService {
|
|||||||
this.mailDelivery = mailDelivery;
|
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
|
@Transactional
|
||||||
public long saveDraft(long adminId, SmtpDraft draft) {
|
public long saveDraft(long adminId, SmtpDraft draft) {
|
||||||
validate(draft);
|
validate(draft);
|
||||||
@@ -53,6 +65,13 @@ public class SmtpConfigurationService {
|
|||||||
return configurations.save(configuration).getId();
|
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) {
|
public void testDraft(long draftId, long adminId, String recipient) {
|
||||||
SmtpConfiguration draft = configurations.findById(draftId)
|
SmtpConfiguration draft = configurations.findById(draftId)
|
||||||
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
|
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
|
||||||
@@ -63,6 +82,12 @@ public class SmtpConfigurationService {
|
|||||||
configurations.save(draft);
|
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
|
@Transactional
|
||||||
public void activate(long draftId, long adminId) {
|
public void activate(long draftId, long adminId) {
|
||||||
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
|
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
|
||||||
@@ -74,6 +99,7 @@ public class SmtpConfigurationService {
|
|||||||
draft.activate(verifiedAdminId, now);
|
draft.activate(verifiedAdminId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return {@code true} when a tested SMTP revision is currently active */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean hasActiveConfiguration() {
|
public boolean hasActiveConfiguration() {
|
||||||
return mailDelivery.isAvailable();
|
return mailDelivery.isAvailable();
|
||||||
@@ -103,11 +129,23 @@ public class SmtpConfigurationService {
|
|||||||
SecurityMode.STARTTLS, null, null, null));
|
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)
|
@Transactional(readOnly = true)
|
||||||
public SmtpConnection activeConnection() {
|
public SmtpConnection activeConnection() {
|
||||||
return mailDelivery.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) {
|
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||||
mailDelivery.send(recipient, subject, body);
|
mailDelivery.send(recipient, subject, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ package com.lab.labtimesheet.feature.integration.service;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||||
|
|
||||||
|
/** External SMTP adapter boundary used by setup tests and application email delivery. */
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface SmtpProbe {
|
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);
|
void send(SmtpConnection connection, String recipient, String subject, String body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<p th:if="${smtpStatus.active}" role="status">SMTP is active.</p>
|
<p th:if="${smtpStatus.active}" role="status">SMTP is active.</p>
|
||||||
<p th:if="${smtpStatus.draftId != null}" role="status">Draft saved.</p>
|
<p th:if="${smtpStatus.draftId != null}" role="status">Draft saved.</p>
|
||||||
<p th:if="${smtpStatus.tested}" role="status">Test passed.</p>
|
<p th:if="${smtpStatus.tested}" role="status">Test passed.</p>
|
||||||
|
<p th:if="${smtpActionError}" th:text="${smtpActionError}" role="alert"></p>
|
||||||
<form method="post" th:action="@{/admin/smtp/draft}" th:object="${smtpForm}">
|
<form method="post" th:action="@{/admin/smtp/draft}" th:object="${smtpForm}">
|
||||||
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
|
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||||
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
|
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
|
||||||
|
|||||||
+32
@@ -11,13 +11,19 @@ import java.util.concurrent.CountDownLatch;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.LabtimesheetApplication;
|
||||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||||
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
@@ -47,6 +53,9 @@ class BootstrapIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private SystemStateRepository systemStates;
|
private SystemStateRepository systemStates;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
|
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
|
||||||
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
|
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
|
||||||
@@ -87,6 +96,29 @@ class BootstrapIntegrationTest {
|
|||||||
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
|
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
|
@Test
|
||||||
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
|
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
|
||||||
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
|
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
|
||||||
|
|||||||
+34
@@ -141,6 +141,36 @@ class SmtpOnboardingWebIntegrationTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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)
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
static class ProbeConfiguration {
|
static class ProbeConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
@@ -152,9 +182,13 @@ class SmtpOnboardingWebIntegrationTest {
|
|||||||
|
|
||||||
static final class RecordingProbe implements SmtpProbe {
|
static final class RecordingProbe implements SmtpProbe {
|
||||||
private final List<String> recipients = new ArrayList<>();
|
private final List<String> recipients = new ArrayList<>();
|
||||||
|
private String failureMessage;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||||
|
if (failureMessage != null) {
|
||||||
|
throw new IllegalStateException(failureMessage);
|
||||||
|
}
|
||||||
recipients.add(recipient);
|
recipients.add(recipient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user