Merge commit '1235204bf1298599264a07943ca1167432556bd2' into work/reports-ui
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
# Test Evidence: Cross-feature account boundary
|
||||||
|
|
||||||
|
- **Test type:** Integration
|
||||||
|
- **Requirement IDs:** `ACC-002, ACC-014, ACC-020–ACC-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-001–ACC-004, SEC-001–SEC-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
|
- **Test type:** Integration
|
||||||
- **Requirement IDs:** `ARC-001–ARC-008, DB-003–DB-012, OPS-003, TST-001–TST-010`
|
- **Requirement IDs:** `ARC-001–ARC-008, DB-003–DB-012, OPS-003, TST-001–TST-010`
|
||||||
- **Scenario IDs:** `AC-DB-001, AC-OPS-002, AC-TST-001`
|
- **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`
|
- **Implementation commit:** `this milestone commit`
|
||||||
|
|
||||||
## Protected behavior
|
## 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
|
## 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
|
## Hand-derived expected result
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Successfully applied 1 migration to schema "public", now at version v1
|
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
|
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
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
./mvnw test
|
./mvnw test
|
||||||
|
|
||||||
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
||||||
BUILD SUCCESS
|
BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Test Evidence: SMTP draft, test, and activation
|
||||||
|
|
||||||
|
- **Test type:** Integration
|
||||||
|
- **Requirement IDs:** `INT-001–INT-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-001–ARC-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.
|
||||||
@@ -31,6 +31,10 @@
|
|||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package com.lab.labtimesheet;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.config.SecurityProperties;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableConfigurationProperties(SecurityProperties.class)
|
||||||
public class LabtimesheetApplication {
|
public class LabtimesheetApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
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
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.lab.labtimesheet.configuration;
|
package com.lab.labtimesheet.config;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
class TimeConfiguration {
|
class TimeConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
Clock applicationClock() {
|
Clock applicationClock() {
|
||||||
return Clock.systemUTC();
|
return Clock.systemUTC();
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.lab.labtimesheet.configuration;
|
|
||||||
|
|
||||||
/** Configuration, integrations, and calendar module boundary. */
|
|
||||||
public final class ModuleBoundary {
|
|
||||||
private ModuleBoundary() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+35
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -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);
|
||||||
|
}
|
||||||
+14
@@ -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);
|
||||||
|
}
|
||||||
+15
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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) {
|
||||||
|
}
|
||||||
+198
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -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() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,3 +6,7 @@ spring:
|
|||||||
mail:
|
mail:
|
||||||
host: ${LAB_SMTP_HOST:localhost}
|
host: ${LAB_SMTP_HOST:localhost}
|
||||||
port: ${LAB_SMTP_PORT:1025}
|
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>
|
||||||
@@ -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.context.annotation.Import;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
|
|
||||||
@Import(TestcontainersConfiguration.class)
|
@Import(TestcontainersConfiguration.class)
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.lab.labtimesheet;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
|
|
||||||
public class TestLabtimesheetApplication {
|
public class TestLabtimesheetApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
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
-16
@@ -1,4 +1,4 @@
|
|||||||
package com.lab.labtimesheet;
|
package com.lab.labtimesheet.config;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ import javax.sql.DataSource;
|
|||||||
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.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
@@ -21,26 +20,12 @@ import org.springframework.test.context.ActiveProfiles;
|
|||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
class PlatformFoundationTest {
|
class PlatformFoundationTest {
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ApplicationContext applicationContext;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private DataSource dataSource;
|
private DataSource dataSource;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private Clock clock;
|
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
|
@Test
|
||||||
void flywayCreatesApprovedPostgresCatalog() {
|
void flywayCreatesApprovedPostgresCatalog() {
|
||||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.lab.labtimesheet;
|
package com.lab.labtimesheet.config;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -12,7 +12,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer;
|
|||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
@TestConfiguration(proxyBeanMethods = false)
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
class TestcontainersConfiguration {
|
public class TestcontainersConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ServiceConnection
|
@ServiceConnection
|
||||||
+103
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user