Merge commit '1235204bf1298599264a07943ca1167432556bd2' into work/tasks

This commit is contained in:
sechmachine
2026-08-15 00:18:28 +07:00
55 changed files with 1826 additions and 67 deletions
@@ -0,0 +1,74 @@
# Test Evidence: Cross-feature account boundary
- **Test type:** Integration
- **Requirement IDs:** `ACC-002, ACC-014, ACC-020ACC-021, PRJ-017, ATT-007`
- **Scenario IDs:** `AC-ACC-002, AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes`
- **Implementation commit:** `this milestone commit`
## Protected behavior
Other features can resolve an account by normalized email or ID through an immutable identity DTO and can ask whether an Intern is active and within an inclusive internship interval for a supplied work date. They do not need access to account repositories or JPA entities.
## Test method
The PostgreSQL 18.4 integration test creates the initial Admin through the production bootstrap transaction, resolves the resulting identity through `AccountService`, and verifies ID/email equivalence, normalized lookup, role, status, and rejection by both current and date-aware Intern eligibility gates. Starting the context also parses the Spring Data derived interval query against the mapped `intern_profiles` entity.
## Hand-derived expected result
` ADMIN@EXAMPLE.COM ` resolves to the persisted `admin@example.com` identity. An active Admin is not an eligible Intern on `2026-08-14`. The date-aware gate requires an active Intern account, an `ACTIVE` internship, and `start_date <= workDate <= end_date`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=BootstrapIntegrationTest test
```
**Observed result**
```text
BootstrapIntegrationTest.java: method isEligibleIntern in class AccountService
cannot be applied to given types; required: long; found: long, java.time.LocalDate
Tests did not run; test compilation failed
BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
The test proves identity lookup and rejection of a non-Intern plus successful repository-query initialization. The positive active-Intern and interval-edge cases remain part of I1-PLAT-06 activation/account lifecycle work; dependent features must still enforce their own authorization and transaction invariants.
@@ -0,0 +1,74 @@
# Test Evidence: Atomic first administrator bootstrap
- **Test type:** Integration
- **Requirement IDs:** `ACC-001ACC-004, SEC-001SEC-002, GOV-013`
- **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001`
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest`
- **Implementation commit:** `this milestone commit`
## Protected behavior
Before initialization only bootstrap and health are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap. The public account service resolves the winning Admin by normalized email or ID without exposing JPA entities or repositories.
## Test method
A PostgreSQL 18.4 integration test releases two Java 25 virtual-thread-safe requests onto the same service concurrently and asserts the row-locked outcomes and database state through Spring Data JPA. MockMvc checks pre/post-bootstrap route exposure, and the account API is checked against the actual concurrent winner.
## Hand-derived expected result
Two simultaneous submissions produce one `CREATED`, one `ALREADY_INITIALIZED`, one Admin row, and one initialized singleton. Later bootstrap requests cannot create another Admin.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest test
```
**Observed result**
```text
BootstrapIntegrationTest.java: cannot find symbol class BootstrapService
17 compilation errors
BUILD FAILURE
```
The public bootstrap behavior did not exist.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The command used the Java 25 and OrbStack environment exports shown above.
## External-test boundaries
This test does not prove deployment-network privacy for the temporary bootstrap route. Operations must still bootstrap on a private interface before public exposure.
@@ -3,16 +3,16 @@
- **Test type:** Integration
- **Requirement IDs:** `ARC-001ARC-008, DB-003DB-012, OPS-003, TST-001TST-010`
- **Scenario IDs:** `AC-DB-001, AC-OPS-002, AC-TST-001`
- **Test class/method:** `com.lab.labtimesheet.PlatformFoundationTest`
- **Test class/method:** `com.lab.labtimesheet.config.PlatformFoundationTest.flywayCreatesApprovedPostgresCatalog`, `com.lab.labtimesheet.config.PlatformFoundationTest.testClockIsDeterministic`
- **Implementation commit:** `this milestone commit`
## Protected behavior
The application starts with the six required package boundaries, Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database.
Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database. Package structure is protected separately by `LayerStructureTest`.
## Test method
A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC catalog queries independently count application tables and foreign keys and inspect the seed. Class loading checks the declared package boundaries, and the injected test `Clock` is asserted exactly.
A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC is used only in this schema/catalog verification test to independently count application tables and foreign keys and inspect the seed. The injected test `Clock` is asserted exactly.
## Hand-derived expected result
@@ -55,7 +55,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
```text
Successfully applied 1 migration to schema "public", now at version v1
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
@@ -69,7 +69,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
+75
View File
@@ -0,0 +1,75 @@
# Test Evidence: SMTP draft, test, and activation
- **Test type:** Integration
- **Requirement IDs:** `INT-001INT-008, ACC-011, SEC-001`
- **Scenario IDs:** `AC-INT-001, AC-INT-002, AC-ACC-004`
- **Test class/method:** `com.lab.labtimesheet.feature.integration.service.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted`
- **Implementation commit:** `this milestone commit`
## Protected behavior
SMTP credentials are AES-256-GCM encrypted, only a successfully tested draft can activate, and a failed test cannot alter the draft into an active configuration.
## Test method
The test persists a draft through Spring Data JPA against PostgreSQL 18.4 using a deterministic test-only master key and a recording SMTP boundary. It forces send failure, inspects database state, rejects activation, then allows the probe and activates the tested draft.
## Hand-derived expected result
Ciphertext must not contain the submitted password. Failure leaves `status=DRAFT` and `tested_at=null`; activation fails. A successful test sets test provenance and permits exactly that draft to become `ACTIVE`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=SmtpIntegrationTest test
```
**Observed result**
```text
SmtpAccountIntegrationTest.java: cannot find symbol class SmtpConfigurationService
SmtpAccountIntegrationTest.java: cannot find symbol class SmtpProbe
17 compilation errors
BUILD FAILURE
```
The SMTP revision and controllable delivery boundaries were absent.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The command used the Java 25 and OrbStack environment exports shown above.
## External-test boundaries
The test intentionally does not contact Mailpit or an external SMTP server. The production adapter is compiled, while delivery semantics are exercised through the recording boundary without network or secret egress.
@@ -0,0 +1,76 @@
# Test Evidence: Package-by-feature structure
- **Test type:** Unit
- **Requirement IDs:** `ARC-001ARC-008`
- **Scenario IDs:** `AC-ARC-001`
- **Test class/method:** `com.lab.labtimesheet.config.LayerStructureTest.applicationUsesOnlyApprovedPackageByFeatureStructure`
- **Implementation commit:** `this milestone commit`
## Protected behavior
The Spring Boot application class remains in the root package, shared wiring remains in `config`, and business code uses only the approved feature and feature-layer packages. Legacy feature-first placeholders, global business layers, and cross-feature repository/entity imports are rejected.
## Test method
A no-dependency JUnit test inspects the production source tree. It checks the root directories, permits the complete seven-feature vocabulary for branch integration, limits nested packages to the approved feature layers, and scans Java imports for persistence leakage across features.
## Hand-derived expected result
The platform branch has only `config` and `feature` below `com.lab.labtimesheet`; its present features are a nonempty subset of account, integration, project, task, attendance, notification, and reporting. A feature may call another feature's public service/DTO API but must not import another feature's repository or entity.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=LayerStructureTest test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
actual directories included exception, controller, projects, configuration,
repository, service, model, accounts, config, attendance, dto, reporting,
and notifications; expected feature and config
BUILD FAILURE
```
The failure exposed both the superseded global-layer worktree and the committed legacy `ModuleBoundary` package placeholders before the corrective move.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=LayerStructureTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This source-tree regression protects package naming and import direction. It does not prove runtime authorization, database transaction behavior, browser flows, containerization, CI, or deployment.
+4
View File
@@ -31,6 +31,10 @@
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
@@ -2,8 +2,12 @@ package com.lab.labtimesheet;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.config.SecurityProperties;
@SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class)
public class LabtimesheetApplication {
public static void main(String[] args) {
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.accounts;
/** Accounts and security module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.attendance;
/** Attendance, leave, and corrections module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -0,0 +1,39 @@
package com.lab.labtimesheet.config;
import com.lab.labtimesheet.feature.account.controller.BootstrapAccessFilter;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
BootstrapAccessFilter bootstrapAccessFilter(BootstrapService bootstrap) {
return new BootstrapAccessFilter(bootstrap);
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, BootstrapAccessFilter bootstrapAccessFilter)
throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/bootstrap/**", "/activate/**", "/login", "/error", "/actuator/health")
.permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(form -> form.defaultSuccessUrl("/", true))
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
.build();
}
}
@@ -0,0 +1,29 @@
package com.lab.labtimesheet.config;
import java.util.Base64;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("lab.security")
public class SecurityProperties {
private String masterKey;
public String getMasterKey() {
return masterKey;
}
public void setMasterKey(String masterKey) {
this.masterKey = masterKey;
}
public byte[] decodedMasterKey() {
if (masterKey == null || masterKey.isBlank()) {
throw new IllegalStateException("lab.security.master-key is required");
}
byte[] decoded = Base64.getDecoder().decode(masterKey);
if (decoded.length != 32) {
throw new IllegalStateException("lab.security.master-key must decode to 256 bits");
}
return decoded;
}
}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.configuration;
package com.lab.labtimesheet.config;
import java.time.Clock;
@@ -7,7 +7,6 @@ import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class TimeConfiguration {
@Bean
Clock applicationClock() {
return Clock.systemUTC();
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.configuration;
/** Configuration, integrations, and calendar module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -0,0 +1,35 @@
package com.lab.labtimesheet.feature.account.controller;
import java.io.IOException;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
public class BootstrapAccessFilter extends OncePerRequestFilter {
private final BootstrapService bootstrap;
public BootstrapAccessFilter(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String path = request.getRequestURI();
if (!bootstrap.isInitialized() && !allowedBeforeBootstrap(path)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
chain.doFilter(request, response);
}
private static boolean allowedBeforeBootstrap(String path) {
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|| path.equals("/actuator/health") || path.startsWith("/bootstrap-assets/")
|| path.equals("/error");
}
}
@@ -0,0 +1,47 @@
package com.lab.labtimesheet.feature.account.controller;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
@Controller
@RequestMapping("/bootstrap")
class BootstrapController {
private final BootstrapService bootstrap;
BootstrapController(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@GetMapping
String form() {
requireOpen();
return "bootstrap/form";
}
@PostMapping
String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password,
Model model) {
try {
if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) {
return "redirect:/login";
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} catch (IllegalArgumentException validation) {
model.addAttribute("error", validation.getMessage());
return "bootstrap/form";
}
}
private void requireOpen() {
if (bootstrap.isInitialized()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.feature.account.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
class HomeController {
@GetMapping("/")
String home() {
return "home";
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.account.model;
public enum AccountStatus {
PENDING_ACTIVATION,
ACTIVE,
LOCKED,
DEACTIVATED
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.account.model;
public enum GlobalRole {
ADMIN,
MENTOR,
INTERN
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.account.model;
public enum InternshipStatus {
NOT_STARTED,
ACTIVE,
COMPLETED,
WITHDRAWN
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.feature.account.model.dto;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
public record AccountIdentity(
long id,
String email,
String displayName,
GlobalRole role,
AccountStatus status) {
}
@@ -0,0 +1,103 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "app_users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 320)
private String email;
@Column(name = "display_name", nullable = false, length = 120)
private String displayName;
@Column(name = "password_hash", length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
private GlobalRole globalRole;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", nullable = false, length = 32)
private AccountStatus accountStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by_user_id")
private AppUser createdBy;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected AppUser() {
}
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
this.email = email;
this.displayName = displayName;
this.passwordHash = passwordHash;
this.globalRole = globalRole;
this.accountStatus = accountStatus;
this.activatedAt = activatedAt;
this.createdBy = createdBy;
this.createdAt = now;
this.updatedAt = 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);
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getPasswordHash() {
return passwordHash;
}
public GlobalRole getGlobalRole() {
return globalRole;
}
public AccountStatus getAccountStatus() {
return accountStatus;
}
}
@@ -0,0 +1,61 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "intern_profiles")
public class InternProfile {
@Id
@Column(name = "user_id")
private Long userId;
@Column(name = "student_code", nullable = false, length = 64)
private String studentCode;
@Column(length = 120)
private String department;
@Column(length = 32)
private String phone;
@Column(name = "internship_start_date", nullable = false)
private LocalDate internshipStartDate;
@Column(name = "internship_end_date", nullable = false)
private LocalDate internshipEndDate;
@Enumerated(EnumType.STRING)
@Column(name = "internship_status", nullable = false, length = 24)
private InternshipStatus internshipStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "withdrawn_at")
private Instant withdrawnAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected InternProfile() {
}
}
@@ -0,0 +1,57 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "system_state")
public class SystemState {
@Id
@Column(name = "singleton_id")
private short singletonId;
@Column(nullable = false)
private boolean initialized;
@Column(name = "initialized_at")
private Instant initializedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bootstrap_admin_id")
private AppUser bootstrapAdmin;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected SystemState() {
}
public boolean isInitialized() {
return initialized;
}
public void initialize(AppUser admin, Instant now) {
if (initialized) {
throw new IllegalStateException("Bootstrap is already complete");
}
initialized = true;
initializedAt = now;
bootstrapAdmin = admin;
updatedAt = now;
}
}
@@ -0,0 +1,17 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.account.repository;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
}
@@ -0,0 +1,15 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from SystemState s where s.singletonId = 1")
Optional<SystemState> findSingletonForUpdate();
}
@@ -0,0 +1,94 @@
package com.lab.labtimesheet.feature.account.service;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
private final AppUserRepository users;
private final InternProfileRepository internProfiles;
AccountService(AppUserRepository users, InternProfileRepository internProfiles) {
this.users = users;
this.internProfiles = internProfiles;
}
@Transactional(readOnly = true)
public AccountIdentity requireIdentityById(long userId) {
return users.findById(userId).map(AccountService::identity)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
}
@Transactional(readOnly = true)
public AccountIdentity requireIdentityByEmail(String email) {
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
}
@Transactional(readOnly = true)
public boolean isEligibleIntern(long userId) {
return users.findById(userId)
.filter(user -> user.getGlobalRole() == GlobalRole.INTERN)
.filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE)
.filter(user -> internProfiles.existsByUserIdAndInternshipStatus(
user.getId(), InternshipStatus.ACTIVE))
.isPresent();
}
@Transactional(readOnly = true)
public boolean isEligibleIntern(long userId, LocalDate workDate) {
if (workDate == null) {
throw new IllegalArgumentException("Work date is required");
}
return users.findById(userId)
.filter(user -> user.getGlobalRole() == GlobalRole.INTERN)
.filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE)
.filter(user -> internProfiles
.existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
user.getId(), InternshipStatus.ACTIVE, workDate, workDate))
.isPresent();
}
@Transactional(readOnly = true)
public AccountIdentity requireEligibleIntern(long userId) {
if (!isEligibleIntern(userId)) {
throw new IllegalArgumentException("An active Intern account and internship are required");
}
return requireIdentityById(userId);
}
@Transactional(readOnly = true)
public long requireActiveAdminId(String email) {
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
.orElseThrow(() -> new IllegalStateException("Authenticated Admin is missing"));
return requireActiveAdmin(user);
}
@Transactional(readOnly = true)
public long requireActiveAdminId(long userId) {
AppUser user = users.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
return requireActiveAdmin(user);
}
private static long requireActiveAdmin(AppUser user) {
if (user.getGlobalRole() != GlobalRole.ADMIN || user.getAccountStatus() != AccountStatus.ACTIVE) {
throw new IllegalArgumentException("An active Admin is required");
}
return user.getId();
}
private static AccountIdentity identity(AppUser user) {
return new AccountIdentity(
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
}
}
@@ -0,0 +1,73 @@
package com.lab.labtimesheet.feature.account.service;
import java.time.Clock;
import java.util.Locale;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BootstrapService {
private final SystemStateRepository systemStates;
private final AppUserRepository users;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
Clock clock) {
this.systemStates = systemStates;
this.users = users;
this.passwords = passwords;
this.clock = clock;
}
@Transactional
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
String normalizedEmail = normalizeEmail(email);
String normalizedName = requireText(displayName, "Display name");
requirePassword(password);
SystemState state = systemStates.findSingletonForUpdate()
.orElseThrow(() -> new IllegalStateException("System state is missing"));
if (state.isInitialized()) {
return BootstrapOutcome.ALREADY_INITIALIZED;
}
var now = clock.instant();
AppUser admin = users.save(AppUser.bootstrapAdmin(
normalizedEmail, normalizedName, passwords.encode(password), now));
state.initialize(admin, now);
return BootstrapOutcome.CREATED;
}
@Transactional(readOnly = true)
public boolean isInitialized() {
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
}
public static String normalizeEmail(String email) {
return requireText(email, "Email").toLowerCase(Locale.ROOT);
}
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");
}
}
private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " is required");
}
return value.trim();
}
public enum BootstrapOutcome {
CREATED,
ALREADY_INITIALIZED
}
}
@@ -0,0 +1,32 @@
package com.lab.labtimesheet.feature.account.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var account = users.findByNormalizedEmail(BootstrapService.normalizeEmail(username))
.orElseThrow(() -> new UsernameNotFoundException("Invalid credentials"));
String hash = account.getPasswordHash();
return User.withUsername(account.getEmail())
.password(hash == null ? "{noop}unavailable" : hash)
.roles(account.getGlobalRole().name())
.disabled(account.getAccountStatus() != AccountStatus.ACTIVE)
.build();
}
}
@@ -0,0 +1,55 @@
package com.lab.labtimesheet.feature.integration.controller;
import java.security.Principal;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping("/admin/smtp")
class SmtpController {
private final SmtpConfigurationService smtp;
private final AccountService accounts;
SmtpController(SmtpConfigurationService smtp, AccountService accounts) {
this.smtp = smtp;
this.accounts = accounts;
}
@GetMapping
String form() {
return "smtp/form";
}
@PostMapping("/draft")
String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode,
@RequestParam(required = false) String username, @RequestParam(required = false) String password,
@RequestParam String fromAddress, @RequestParam String fromName, Principal principal) {
smtp.saveDraft(adminId(principal),
new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName));
return "redirect:/admin/smtp";
}
@PostMapping("/test")
String test(@RequestParam long draftId, Principal principal) {
smtp.testDraft(draftId, adminId(principal), principal.getName());
return "redirect:/admin/smtp";
}
@PostMapping("/activate")
String activate(@RequestParam long draftId, Principal principal) {
smtp.activate(draftId, adminId(principal));
return "redirect:/admin/smtp";
}
private long adminId(Principal principal) {
return accounts.requireActiveAdminId(principal.getName());
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model;
public enum SecurityMode {
NONE,
STARTTLS,
TLS
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model;
public enum SmtpStatus {
DRAFT,
ACTIVE,
RETIRED
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.integration.model.dto;
public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
public EncryptedSecret {
ciphertext = ciphertext.clone();
nonce = nonce.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
@Override
public byte[] nonce() {
return nonce.clone();
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model.dto;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model.dto;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
@@ -0,0 +1,198 @@
package com.lab.labtimesheet.feature.integration.model.entity;
import java.time.Instant;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "smtp_configurations")
public class SmtpConfiguration {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private SmtpStatus status;
@Column(nullable = false, length = 255)
private String host;
@Column(nullable = false)
private int port;
@Enumerated(EnumType.STRING)
@Column(name = "security_mode", nullable = false, length = 16)
private SecurityMode securityMode;
@Column(length = 320)
private String username;
@Column(name = "password_ciphertext")
private byte[] passwordCiphertext;
@Column(name = "password_nonce")
private byte[] passwordNonce;
@Column(name = "secret_key_version")
private Integer secretKeyVersion;
@Column(name = "from_address", nullable = false, length = 320)
private String fromAddress;
@Column(name = "from_name", nullable = false, length = 120)
private String fromName;
@Column(name = "tested_at")
private Instant testedAt;
@Column(name = "tested_by_user_id")
private Long testedByUserId;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "activated_by_user_id")
private Long activatedByUserId;
@Column(name = "retired_at")
private Instant retiredAt;
@Column(name = "retired_by_user_id")
private Long retiredByUserId;
@Column(name = "created_by_user_id", nullable = false)
private Long createdByUserId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected SmtpConfiguration() {
}
public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) {
SmtpConfiguration configuration = new SmtpConfiguration();
configuration.status = SmtpStatus.DRAFT;
configuration.createdByUserId = adminId;
configuration.createdAt = now;
configuration.updateDraft(draft, password, now);
return configuration;
}
public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) {
if (status != SmtpStatus.DRAFT) {
throw new IllegalStateException("Only an SMTP draft can be edited");
}
host = draft.host().trim();
port = draft.port();
securityMode = draft.securityMode();
username = clean(draft.username());
passwordCiphertext = password == null ? null : password.ciphertext();
passwordNonce = password == null ? null : password.nonce();
secretKeyVersion = password == null ? null : password.keyVersion();
fromAddress = draft.fromAddress().trim();
fromName = draft.fromName().trim();
testedAt = null;
testedByUserId = null;
updatedAt = now;
}
public void markTested(long adminId, Instant now) {
if (status != SmtpStatus.DRAFT) {
throw new IllegalStateException("SMTP draft is no longer available");
}
testedAt = now;
testedByUserId = adminId;
updatedAt = now;
}
public void activate(long adminId, Instant now) {
if (status != SmtpStatus.DRAFT || testedAt == null) {
throw new IllegalStateException("SMTP draft must pass a test before activation");
}
status = SmtpStatus.ACTIVE;
activatedAt = now;
activatedByUserId = adminId;
updatedAt = now;
}
public void retire(long adminId, Instant now) {
if (status != SmtpStatus.ACTIVE) {
throw new IllegalStateException("Only active SMTP can be retired");
}
status = SmtpStatus.RETIRED;
retiredAt = now;
retiredByUserId = adminId;
updatedAt = now;
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public Long getId() {
return id;
}
public SmtpStatus getStatus() {
return status;
}
public String getHost() {
return host;
}
public int getPort() {
return port;
}
public SecurityMode getSecurityMode() {
return securityMode;
}
public String getUsername() {
return username;
}
public byte[] getPasswordCiphertext() {
return passwordCiphertext == null ? null : passwordCiphertext.clone();
}
public byte[] getPasswordNonce() {
return passwordNonce == null ? null : passwordNonce.clone();
}
public Integer getSecretKeyVersion() {
return secretKeyVersion;
}
public String getFromAddress() {
return fromAddress;
}
public String getFromName() {
return fromName;
}
public Instant getTestedAt() {
return testedAt;
}
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.integration.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
boolean existsByStatus(SmtpStatus status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
}
@@ -0,0 +1,34 @@
package com.lab.labtimesheet.feature.integration.service;
import java.util.Properties;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.stereotype.Component;
@Component
class JavaMailSmtpProbe implements SmtpProbe {
@Override
public void send(SmtpConnection connection, String recipient, String subject, String body) {
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(connection.host());
sender.setPort(connection.port());
sender.setUsername(connection.username());
sender.setPassword(connection.password());
Properties properties = sender.getJavaMailProperties();
if (connection.securityMode() == SecurityMode.STARTTLS) {
properties.setProperty("mail.smtp.starttls.enable", "true");
properties.setProperty("mail.smtp.starttls.required", "true");
} else if (connection.securityMode() == SecurityMode.TLS) {
sender.setProtocol("smtps");
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(connection.fromAddress());
message.setTo(recipient);
message.setSubject(subject);
message.setText(body);
sender.send(message);
}
}
@@ -0,0 +1,47 @@
package com.lab.labtimesheet.feature.integration.service;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import com.lab.labtimesheet.config.SecurityProperties;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
@Component
public class SecretCipher {
private static final int NONCE_BYTES = 12;
private static final int GCM_TAG_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
SecretCipher(SecurityProperties properties) {
key = new SecretKeySpec(properties.decodedMasterKey(), "AES");
}
EncryptedSecret encrypt(String plaintext) {
byte[] nonce = new byte[NONCE_BYTES];
random.nextBytes(nonce);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, nonce));
return new EncryptedSecret(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)), nonce, 1);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to encrypt integration secret", exception);
}
}
String decrypt(byte[] ciphertext, byte[] nonce) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, nonce));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to decrypt integration secret", exception);
}
}
}
@@ -0,0 +1,116 @@
package com.lab.labtimesheet.feature.integration.service;
import java.time.Clock;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SmtpConfigurationService {
private final SmtpConfigurationRepository configurations;
private final AccountService accounts;
private final SecretCipher secrets;
private final SmtpProbe probe;
private final Environment environment;
private final Clock clock;
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
this.configurations = configurations;
this.accounts = accounts;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
}
@Transactional
public long saveDraft(long adminId, SmtpDraft draft) {
validate(draft);
EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
var now = clock.instant();
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
SmtpConfiguration configuration = configurations.findByStatus(SmtpStatus.DRAFT)
.map(existing -> {
existing.updateDraft(draft, password, now);
return existing;
})
.orElseGet(() -> SmtpConfiguration.draft(draft, password, verifiedAdminId, now));
return configurations.save(configuration).getId();
}
public void testDraft(long draftId, long adminId, String recipient) {
SmtpConfiguration draft = configurations.findById(draftId)
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
.orElseThrow(() -> new IllegalStateException("SMTP configuration is not available"));
probe.send(connection(draft), recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
draft.markTested(verifiedAdminId, clock.instant());
configurations.save(draft);
}
@Transactional
public void activate(long draftId, long adminId) {
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
.orElseThrow(() -> new IllegalStateException("SMTP draft must pass a test before activation"));
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
var now = clock.instant();
configurations.findByStatus(SmtpStatus.ACTIVE)
.ifPresent(active -> active.retire(verifiedAdminId, now));
draft.activate(verifiedAdminId, now);
}
@Transactional(readOnly = true)
public boolean hasActiveConfiguration() {
return configurations.existsByStatus(SmtpStatus.ACTIVE);
}
@Transactional(readOnly = true)
public SmtpConnection activeConnection() {
return configurations.findByStatus(SmtpStatus.ACTIVE)
.map(this::connection)
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
}
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
}
private SmtpConnection connection(SmtpConfiguration configuration) {
byte[] ciphertext = configuration.getPasswordCiphertext();
return new SmtpConnection(
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
configuration.getUsername(),
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
configuration.getFromAddress(), configuration.getFromName());
}
private void validate(SmtpDraft draft) {
if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535
|| draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank()
|| draft.fromName() == null || draft.fromName().isBlank()) {
throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required");
}
if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) {
throw new IllegalArgumentException("SMTP username and password must be supplied together");
}
if (draft.securityMode() == SecurityMode.NONE
&& !environment.acceptsProfiles(Profiles.of("dev", "test"))) {
throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test");
}
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.integration.service;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
@FunctionalInterface
public interface SmtpProbe {
void send(SmtpConnection connection, String recipient, String subject, String body);
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.notifications;
/** Notifications module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.projects;
/** Projects and tasks module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.reporting;
/** Reporting module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
+4
View File
@@ -6,3 +6,7 @@ spring:
mail:
host: ${LAB_SMTP_HOST:localhost}
port: ${LAB_SMTP_PORT:1025}
lab:
security:
# Explicit non-production key; production must supply its own 256-bit key.
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Initialize Lab Timesheet</title></head>
<body>
<main>
<h1>Create the first administrator</h1>
<p th:if="${error}" th:text="${error}" role="alert"></p>
<form method="post" th:action="@{/bootstrap}">
<label>Email <input name="email" type="email" required autocomplete="email"></label>
<label>Display name <input name="displayName" required autocomplete="name"></label>
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<button type="submit">Create administrator</button>
</form>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Lab Timesheet</title></head>
<body><main><h1>Lab Timesheet</h1></main></body>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>SMTP configuration</title></head>
<body>
<main>
<h1>SMTP configuration</h1>
<form method="post" th:action="@{/admin/smtp/draft}">
<label>Host <input name="host" required></label>
<label>Port <input name="port" type="number" min="1" max="65535" required></label>
<label>Security <select name="securityMode"><option>STARTTLS</option><option>TLS</option><option>NONE</option></select></label>
<label>Username <input name="username" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="new-password"></label>
<label>From address <input name="fromAddress" type="email" required></label>
<label>From name <input name="fromName" required></label>
<button type="submit">Save draft</button>
</form>
</main>
</body>
</html>
@@ -5,6 +5,8 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@ActiveProfiles("test")
@@ -2,6 +2,8 @@ package com.lab.labtimesheet;
import org.springframework.boot.SpringApplication;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
public class TestLabtimesheetApplication {
public static void main(String[] args) {
@@ -0,0 +1,83 @@
package com.lab.labtimesheet.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.lab.labtimesheet.LabtimesheetApplication;
import org.junit.jupiter.api.Test;
class LayerStructureTest {
private static final Path BASE_PACKAGE = Path.of("src/main/java/com/lab/labtimesheet");
private static final Set<String> APPROVED_ROOT_PACKAGES = Set.of("config", "feature");
private static final Set<String> APPROVED_FEATURES = Set.of(
"account", "integration", "project", "task", "attendance", "notification", "reporting");
private static final Set<String> APPROVED_FEATURE_PACKAGES = Set.of(
"controller", "exception", "model", "model/dto", "model/entity", "repository", "service");
private static final Pattern INTERNAL_IMPORT = Pattern.compile(
"import com\\.lab\\.labtimesheet\\.feature\\.([^.]+)\\.(?:repository|model\\.entity)\\.");
@Test
void applicationUsesOnlyApprovedPackageByFeatureStructure() throws IOException {
assertThat(LabtimesheetApplication.class.getPackageName()).isEqualTo("com.lab.labtimesheet");
try (var entries = Files.list(BASE_PACKAGE)) {
Set<String> directories = entries
.filter(Files::isDirectory)
.map(path -> path.getFileName().toString())
.collect(Collectors.toSet());
assertThat(directories).containsExactlyInAnyOrderElementsOf(APPROVED_ROOT_PACKAGES);
}
Path featurePackage = BASE_PACKAGE.resolve("feature");
try (var entries = Files.list(featurePackage)) {
Set<String> features = entries
.filter(Files::isDirectory)
.map(path -> path.getFileName().toString())
.collect(Collectors.toSet());
assertThat(features).isNotEmpty().isSubsetOf(APPROVED_FEATURES);
}
try (var entries = Files.walk(featurePackage)) {
List<String> featurePackages = entries
.filter(Files::isDirectory)
.filter(path -> path.getNameCount() > featurePackage.getNameCount() + 1)
.map(path -> path.subpath(featurePackage.getNameCount() + 1, path.getNameCount()).toString())
.toList();
assertThat(featurePackages).allMatch(APPROVED_FEATURE_PACKAGES::contains);
}
try (var entries = Files.walk(featurePackage)) {
List<String> crossFeaturePersistenceImports = entries
.filter(path -> path.toString().endsWith(".java"))
.flatMap(path -> persistenceImportsFromAnotherFeature(featurePackage, path).stream())
.toList();
assertThat(crossFeaturePersistenceImports).isEmpty();
}
}
private static List<String> persistenceImportsFromAnotherFeature(Path featurePackage, Path source) {
String owningFeature = featurePackage.relativize(source).getName(0).toString();
try {
return Files.readAllLines(source).stream()
.filter(line -> {
var matcher = INTERNAL_IMPORT.matcher(line);
return matcher.find() && !matcher.group(1).equals(owningFeature);
})
.map(line -> source + ": " + line.trim())
.toList();
} catch (IOException exception) {
throw new IllegalStateException("Cannot inspect " + source, exception);
}
}
}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet;
package com.lab.labtimesheet.config;
import static org.assertj.core.api.Assertions.assertThat;
@@ -11,7 +11,6 @@ import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
@@ -21,26 +20,12 @@ import org.springframework.test.context.ActiveProfiles;
@ActiveProfiles("test")
class PlatformFoundationTest {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private DataSource dataSource;
@Autowired
private Clock clock;
@Test
void applicationExposesRequiredModulePackages() throws ClassNotFoundException {
assertThat(applicationContext).isNotNull();
assertThat(Class.forName("com.lab.labtimesheet.accounts.ModuleBoundary")).isNotNull();
assertThat(Class.forName("com.lab.labtimesheet.configuration.ModuleBoundary")).isNotNull();
assertThat(Class.forName("com.lab.labtimesheet.projects.ModuleBoundary")).isNotNull();
assertThat(Class.forName("com.lab.labtimesheet.attendance.ModuleBoundary")).isNotNull();
assertThat(Class.forName("com.lab.labtimesheet.notifications.ModuleBoundary")).isNotNull();
assertThat(Class.forName("com.lab.labtimesheet.reporting.ModuleBoundary")).isNotNull();
}
@Test
void flywayCreatesApprovedPostgresCatalog() {
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
@@ -1,4 +1,4 @@
package com.lab.labtimesheet;
package com.lab.labtimesheet.config;
import java.time.Clock;
import java.time.Instant;
@@ -12,7 +12,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
@@ -0,0 +1,103 @@
package com.lab.labtimesheet.feature.account.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
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 org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BootstrapIntegrationTest {
@Autowired
private BootstrapService bootstrapService;
@Autowired
private AccountService accountService;
@Autowired
private MockMvc mockMvc;
@Autowired
private AppUserRepository users;
@Autowired
private SystemStateRepository systemStates;
@Test
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health")).andExpect(status().isOk());
mockMvc.perform(get("/")).andExpect(status().isNotFound());
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
mockMvc.perform(get("/bootstrap")).andExpect(status().isNotFound());
}
@Test
void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<BootstrapService.BootstrapOutcome>> futures = new ArrayList<>();
try (var executor = Executors.newFixedThreadPool(2)) {
for (int i = 0; i < 2; i++) {
int suffix = i;
futures.add(executor.submit(() -> {
ready.countDown();
start.await();
return bootstrapService.bootstrap(
"admin" + suffix + "@example.com", "First Admin", "correct horse battery staple");
}));
}
ready.await();
start.countDown();
}
assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder(
BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(users.count()).isEqualTo(1);
assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1);
assertThat(bootstrapService.bootstrap(
"another@example.com", "Another", "correct horse battery staple"))
.isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
}
@Test
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
var identityByEmail = accountService.requireIdentityByEmail(" ADMIN@EXAMPLE.COM ");
assertThat(identityByEmail.email()).isEqualTo("admin@example.com");
assertThat(identityByEmail.displayName()).isEqualTo("First Admin");
assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN);
assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE);
assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail);
assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse();
assertThat(accountService.isEligibleIntern(identityByEmail.id(), LocalDate.of(2026, 8, 14))).isFalse();
}
}
@@ -0,0 +1,91 @@
package com.lab.labtimesheet.feature.integration.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ActiveProfiles;
@Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class})
@SpringBootTest
@ActiveProfiles("test")
class SmtpIntegrationTest {
@Autowired
private BootstrapService bootstrapService;
@Autowired
private AccountService accountService;
@Autowired
private SmtpConfigurationService smtpService;
@Autowired
private RecordingSmtpProbe smtpProbe;
@Autowired
private SmtpConfigurationRepository configurations;
@Test
void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() {
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
long adminId = accountService.requireActiveAdminId("admin@example.com");
long draftId = smtpService.saveDraft(adminId, new SmtpDraft(
"mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab"));
var savedDraft = configurations.findById(draftId).orElseThrow();
byte[] ciphertext = savedDraft.getPasswordCiphertext();
assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password");
assertThat(savedDraft.getPasswordNonce()).hasSize(12);
assertThat(savedDraft.getSecretKeyVersion()).isEqualTo(1);
smtpProbe.fail = true;
assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com"))
.isInstanceOf(IllegalStateException.class);
assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.DRAFT);
assertThat(configurations.findById(draftId).orElseThrow().getTestedAt()).isNull();
assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class);
smtpProbe.fail = false;
smtpService.testDraft(draftId, adminId, "admin@example.com");
smtpService.activate(draftId, adminId);
assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.ACTIVE);
}
@TestConfiguration(proxyBeanMethods = false)
static class MailProbeConfiguration {
@Bean
@Primary
RecordingSmtpProbe recordingSmtpProbe() {
return new RecordingSmtpProbe();
}
}
static final class RecordingSmtpProbe implements SmtpProbe {
private boolean fail;
@Override
public void send(SmtpConnection connection, String recipient, String subject,
String body) {
if (fail) {
throw new IllegalStateException("simulated SMTP failure");
}
}
}
}