refactor: adopt feature package boundaries and JPA

This commit is contained in:
sechmachine
2026-08-15 00:14:21 +07:00
parent bc70db1d0d
commit 3fdfbb2bf2
54 changed files with 1137 additions and 466 deletions
@@ -3,16 +3,16 @@
- **Test type:** Integration - **Test type:** Integration
- **Requirement IDs:** `ACC-001ACC-004, SEC-001SEC-002, GOV-013` - **Requirement IDs:** `ACC-001ACC-004, SEC-001SEC-002, GOV-013`
- **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001` - **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001`
- **Test class/method:** `com.lab.labtimesheet.BootstrapIntegrationTest` - **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest`
- **Implementation commit:** `this milestone commit` - **Implementation commit:** `this milestone commit`
## Protected behavior ## 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. 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 ## 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. MockMvc checks pre/post-bootstrap route exposure. 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 ## Hand-derived expected result
@@ -26,7 +26,7 @@ Two simultaneous submissions produce one `CREATED`, one `ALREADY_INITIALIZED`, o
export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" 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 -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test ./mvnw -Dtest=BootstrapIntegrationTest test
``` ```
**Observed result** **Observed result**
@@ -53,7 +53,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
**Observed result** **Observed result**
```text ```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
@@ -3,16 +3,16 @@
- **Test type:** Integration - **Test type:** Integration
- **Requirement IDs:** `ARC-001ARC-008, DB-003DB-012, OPS-003, TST-001TST-010` - **Requirement IDs:** `ARC-001ARC-008, DB-003DB-012, OPS-003, TST-001TST-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: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
+4 -4
View File
@@ -3,7 +3,7 @@
- **Test type:** Integration - **Test type:** Integration
- **Requirement IDs:** `INT-001INT-008, ACC-011, SEC-001` - **Requirement IDs:** `INT-001INT-008, ACC-011, SEC-001`
- **Scenario IDs:** `AC-INT-001, AC-INT-002, AC-ACC-004` - **Scenario IDs:** `AC-INT-001, AC-INT-002, AC-ACC-004`
- **Test class/method:** `com.lab.labtimesheet.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted` - **Test class/method:** `com.lab.labtimesheet.feature.integration.service.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted`
- **Implementation commit:** `this milestone commit` - **Implementation commit:** `this milestone commit`
## Protected behavior ## Protected behavior
@@ -12,7 +12,7 @@ SMTP credentials are AES-256-GCM encrypted, only a successfully tested draft can
## Test method ## Test method
The test persists a draft 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. 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 ## Hand-derived expected result
@@ -26,7 +26,7 @@ Ciphertext must not contain the submitted password. Failure leaves `status=DRAFT
export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" 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 -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test ./mvnw -Dtest=SmtpIntegrationTest test
``` ```
**Observed result** **Observed result**
@@ -54,7 +54,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
**Observed result** **Observed result**
```text ```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
``` ```
@@ -0,0 +1,76 @@
# Test Evidence: Package-by-feature structure
- **Test type:** Unit
- **Requirement IDs:** `ARC-001ARC-008`
- **Scenario IDs:** `AC-ARC-001`
- **Test class/method:** `com.lab.labtimesheet.config.LayerStructureTest.applicationUsesOnlyApprovedPackageByFeatureStructure`
- **Implementation commit:** `this milestone commit`
## Protected behavior
The Spring Boot application class remains in the root package, shared wiring remains in `config`, and business code uses only the approved feature and feature-layer packages. Legacy feature-first placeholders, global business layers, and cross-feature repository/entity imports are rejected.
## Test method
A no-dependency JUnit test inspects the production source tree. It checks the root directories, permits the complete seven-feature vocabulary for branch integration, limits nested packages to the approved feature layers, and scans Java imports for persistence leakage across features.
## Hand-derived expected result
The platform branch has only `config` and `feature` below `com.lab.labtimesheet`; its present features are a nonempty subset of account, integration, project, task, attendance, notification, and reporting. A feature may call another feature's public service/DTO API but must not import another feature's repository or entity.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=LayerStructureTest test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
actual directories included exception, controller, projects, configuration,
repository, service, model, accounts, config, attendance, dto, reporting,
and notifications; expected feature and config
BUILD FAILURE
```
The failure exposed both the superseded global-layer worktree and the committed legacy `ModuleBoundary` package placeholders before the corrective move.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=LayerStructureTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This source-tree regression protects package naming and import direction. It does not prove runtime authorization, database transaction behavior, browser flows, containerization, CI, or deployment.
@@ -4,7 +4,7 @@ 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 org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.configuration.SecurityProperties; import com.lab.labtimesheet.config.SecurityProperties;
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class) @EnableConfigurationProperties(SecurityProperties.class)
@@ -1,82 +0,0 @@
package com.lab.labtimesheet.accounts;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Locale;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class BootstrapService {
private final JdbcTemplate jdbc;
private final TransactionTemplate transactions;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(JdbcTemplate jdbc, TransactionTemplate transactions, PasswordEncoder passwords, Clock clock) {
this.jdbc = jdbc;
this.transactions = transactions;
this.passwords = passwords;
this.clock = clock;
}
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
String normalizedEmail = normalizeEmail(email);
String normalizedName = requireText(displayName, "Display name");
requirePassword(password);
return transactions.execute(status -> {
Boolean initialized = jdbc.queryForObject(
"select initialized from system_state where singleton_id = 1 for update", Boolean.class);
if (Boolean.TRUE.equals(initialized)) {
return BootstrapOutcome.ALREADY_INITIALIZED;
}
OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
Long userId = jdbc.queryForObject("""
insert into app_users
(email, display_name, password_hash, global_role, account_status, activated_at, created_at, updated_at)
values (?, ?, ?, 'ADMIN', 'ACTIVE', ?, ?, ?)
returning id
""", Long.class, normalizedEmail, normalizedName, passwords.encode(password), now, now, now);
jdbc.update("""
update system_state
set initialized = true, initialized_at = ?, bootstrap_admin_id = ?, updated_at = ?, version = version + 1
where singleton_id = 1
""", now, userId, now);
return BootstrapOutcome.CREATED;
});
}
public boolean isInitialized() {
return Boolean.TRUE.equals(jdbc.queryForObject(
"select initialized from system_state where singleton_id = 1", Boolean.class));
}
static String normalizeEmail(String email) {
return requireText(email, "Email").toLowerCase(Locale.ROOT);
}
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
}
}
@@ -1,37 +0,0 @@
package com.lab.labtimesheet.accounts;
import org.springframework.jdbc.core.JdbcTemplate;
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;
@Service
class JdbcUserDetailsService implements UserDetailsService {
private final JdbcTemplate jdbc;
JdbcUserDetailsService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String email = BootstrapService.normalizeEmail(username);
return jdbc.query("""
select email, password_hash, global_role, account_status
from app_users where lower(btrim(email)) = ?
""", resultSet -> {
if (!resultSet.next()) {
throw new UsernameNotFoundException("Invalid credentials");
}
boolean active = "ACTIVE".equals(resultSet.getString("account_status"));
String hash = resultSet.getString("password_hash");
return User.withUsername(resultSet.getString("email"))
.password(hash == null ? "{noop}unavailable" : hash)
.roles(resultSet.getString("global_role"))
.disabled(!active)
.build();
}, email);
}
}
@@ -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() {
}
}
@@ -1,5 +1,7 @@
package com.lab.labtimesheet.accounts; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -10,7 +12,6 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter;
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
class SecurityConfiguration { class SecurityConfiguration {
@Bean @Bean
PasswordEncoder passwordEncoder() { PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); return PasswordEncoderFactories.createDelegatingPasswordEncoder();
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.configuration; package com.lab.labtimesheet.config;
import java.util.Base64; import java.util.Base64;
@@ -16,7 +16,7 @@ public class SecurityProperties {
this.masterKey = masterKey; this.masterKey = masterKey;
} }
byte[] decodedMasterKey() { public byte[] decodedMasterKey() {
if (masterKey == null || masterKey.isBlank()) { if (masterKey == null || masterKey.isBlank()) {
throw new IllegalStateException("lab.security.master-key is required"); throw new IllegalStateException("lab.security.master-key is required");
} }
@@ -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() {
}
}
@@ -1,183 +0,0 @@
package com.lab.labtimesheet.configuration;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class SmtpConfigurationService {
private final JdbcTemplate jdbc;
private final TransactionTemplate transactions;
private final SecretCipher secrets;
private final SmtpProbe probe;
private final Environment environment;
private final Clock clock;
SmtpConfigurationService(JdbcTemplate jdbc, TransactionTemplate transactions, SecretCipher secrets,
SmtpProbe probe, Environment environment, Clock clock) {
this.jdbc = jdbc;
this.transactions = transactions;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
}
public long saveDraft(long adminId, SmtpDraft draft) {
validate(draft);
SecretCipher.EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
OffsetDateTime now = now();
return transactions.execute(status -> {
Long existing = jdbc.query("select id from smtp_configurations where status = 'DRAFT' for update",
resultSet -> resultSet.next() ? resultSet.getLong(1) : null);
Object[] values = values(draft, password, adminId, now);
if (existing == null) {
return jdbc.queryForObject("""
insert into smtp_configurations
(status, host, port, security_mode, username, password_ciphertext, password_nonce,
secret_key_version, from_address, from_name, created_by_user_id, created_at, updated_at)
values ('DRAFT', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
returning id
""", Long.class, values);
}
jdbc.update("""
update smtp_configurations
set host = ?, port = ?, security_mode = ?, username = ?, password_ciphertext = ?,
password_nonce = ?, secret_key_version = ?, from_address = ?, from_name = ?,
tested_at = null, tested_by_user_id = null, updated_at = ?, version = version + 1
where id = ?
""", draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
now, existing);
return existing;
});
}
private static Object[] values(SmtpDraft draft, SecretCipher.EncryptedSecret password, long adminId,
OffsetDateTime now) {
return new Object[] {
draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
adminId, now, now
};
}
public void testDraft(long draftId, long adminId, String recipient) {
SmtpConnection connection = load(draftId, "DRAFT");
probe.send(connection, recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
OffsetDateTime now = now();
if (jdbc.update("""
update smtp_configurations
set tested_at = ?, tested_by_user_id = ?, updated_at = ?, version = version + 1
where id = ? and status = 'DRAFT'
""", now, adminId, now, draftId) != 1) {
throw new IllegalStateException("SMTP draft is no longer available");
}
}
public void activate(long draftId, long adminId) {
transactions.executeWithoutResult(status -> {
OffsetDateTime testedAt = jdbc.query("""
select tested_at from smtp_configurations where id = ? and status = 'DRAFT' for update
""", resultSet -> resultSet.next() ? resultSet.getObject(1, OffsetDateTime.class) : null, draftId);
if (testedAt == null) {
throw new IllegalStateException("SMTP draft must pass a test before activation");
}
OffsetDateTime now = now();
jdbc.update("""
update smtp_configurations
set status = 'RETIRED', retired_at = ?, retired_by_user_id = ?, updated_at = ?, version = version + 1
where status = 'ACTIVE'
""", now, adminId, now);
jdbc.update("""
update smtp_configurations
set status = 'ACTIVE', activated_at = ?, activated_by_user_id = ?, updated_at = ?, version = version + 1
where id = ? and status = 'DRAFT'
""", now, adminId, now, draftId);
});
}
public boolean hasActiveConfiguration() {
return jdbc.queryForObject("select exists(select 1 from smtp_configurations where status = 'ACTIVE')",
Boolean.class);
}
public SmtpConnection activeConnection() {
return jdbc.query("select id from smtp_configurations where status = 'ACTIVE'",
resultSet -> {
if (!resultSet.next()) {
throw new IllegalStateException("Active SMTP configuration is required");
}
return load(resultSet.getLong(1), "ACTIVE");
});
}
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
}
private SmtpConnection load(long id, String requiredStatus) {
return jdbc.query("""
select host, port, security_mode, username, password_ciphertext, password_nonce,
from_address, from_name
from smtp_configurations where id = ? and status = ?
""", resultSet -> {
if (!resultSet.next()) {
throw new IllegalStateException("SMTP configuration is not available");
}
byte[] ciphertext = resultSet.getBytes("password_ciphertext");
return new SmtpConnection(
resultSet.getString("host"), resultSet.getInt("port"),
SecurityMode.valueOf(resultSet.getString("security_mode")),
resultSet.getString("username"),
ciphertext == null ? null : secrets.decrypt(ciphertext, resultSet.getBytes("password_nonce")),
resultSet.getString("from_address"), resultSet.getString("from_name"));
}, id, requiredStatus);
}
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 OffsetDateTime now() {
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public enum SecurityMode {
NONE,
STARTTLS,
TLS
}
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
}
@@ -1,6 +0,0 @@
package com.lab.labtimesheet.configuration;
@FunctionalInterface
public interface SmtpProbe {
void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body);
}
@@ -1,17 +1,18 @@
package com.lab.labtimesheet.accounts; package com.lab.labtimesheet.feature.account.controller;
import java.io.IOException; import java.io.IOException;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
class BootstrapAccessFilter extends OncePerRequestFilter { public class BootstrapAccessFilter extends OncePerRequestFilter {
private final BootstrapService bootstrap; private final BootstrapService bootstrap;
BootstrapAccessFilter(BootstrapService bootstrap) { public BootstrapAccessFilter(BootstrapService bootstrap) {
this.bootstrap = bootstrap; this.bootstrap = bootstrap;
} }
@@ -1,5 +1,6 @@
package com.lab.labtimesheet.accounts; package com.lab.labtimesheet.feature.account.controller;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.accounts; package com.lab.labtimesheet.feature.account.controller;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.account.model;
public enum AccountStatus {
PENDING_ACTIVATION,
ACTIVE,
LOCKED,
DEACTIVATED
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.account.model;
public enum GlobalRole {
ADMIN,
MENTOR,
INTERN
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.account.model;
public enum InternshipStatus {
NOT_STARTED,
ACTIVE,
COMPLETED,
WITHDRAWN
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.feature.account.model.dto;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
public record AccountIdentity(
long id,
String email,
String displayName,
GlobalRole role,
AccountStatus status) {
}
@@ -0,0 +1,103 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "app_users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 320)
private String email;
@Column(name = "display_name", nullable = false, length = 120)
private String displayName;
@Column(name = "password_hash", length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
private GlobalRole globalRole;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", nullable = false, length = 32)
private AccountStatus accountStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by_user_id")
private AppUser createdBy;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected AppUser() {
}
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
this.email = email;
this.displayName = displayName;
this.passwordHash = passwordHash;
this.globalRole = globalRole;
this.accountStatus = accountStatus;
this.activatedAt = activatedAt;
this.createdBy = createdBy;
this.createdAt = now;
this.updatedAt = now;
}
public static AppUser bootstrapAdmin(String email, String displayName, String passwordHash, Instant now) {
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getPasswordHash() {
return passwordHash;
}
public GlobalRole getGlobalRole() {
return globalRole;
}
public AccountStatus getAccountStatus() {
return accountStatus;
}
}
@@ -0,0 +1,61 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "intern_profiles")
public class InternProfile {
@Id
@Column(name = "user_id")
private Long userId;
@Column(name = "student_code", nullable = false, length = 64)
private String studentCode;
@Column(length = 120)
private String department;
@Column(length = 32)
private String phone;
@Column(name = "internship_start_date", nullable = false)
private LocalDate internshipStartDate;
@Column(name = "internship_end_date", nullable = false)
private LocalDate internshipEndDate;
@Enumerated(EnumType.STRING)
@Column(name = "internship_status", nullable = false, length = 24)
private InternshipStatus internshipStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "withdrawn_at")
private Instant withdrawnAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected InternProfile() {
}
}
@@ -0,0 +1,57 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "system_state")
public class SystemState {
@Id
@Column(name = "singleton_id")
private short singletonId;
@Column(nullable = false)
private boolean initialized;
@Column(name = "initialized_at")
private Instant initializedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bootstrap_admin_id")
private AppUser bootstrapAdmin;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected SystemState() {
}
public boolean isInitialized() {
return initialized;
}
public void initialize(AppUser admin, Instant now) {
if (initialized) {
throw new IllegalStateException("Bootstrap is already complete");
}
initialized = true;
initializedAt = now;
bootstrapAdmin = admin;
updatedAt = now;
}
}
@@ -0,0 +1,17 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
}
@@ -0,0 +1,9 @@
package com.lab.labtimesheet.feature.account.repository;
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);
}
@@ -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,78 @@
package com.lab.labtimesheet.feature.account.service;
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 AccountIdentity requireEligibleIntern(long userId) {
if (!isEligibleIntern(userId)) {
throw new IllegalArgumentException("An active Intern account and internship are required");
}
return requireIdentityById(userId);
}
@Transactional(readOnly = true)
public long requireActiveAdminId(String email) {
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
.orElseThrow(() -> new IllegalStateException("Authenticated Admin is missing"));
return requireActiveAdmin(user);
}
@Transactional(readOnly = true)
public long requireActiveAdminId(long userId) {
AppUser user = users.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
return requireActiveAdmin(user);
}
private static long requireActiveAdmin(AppUser user) {
if (user.getGlobalRole() != GlobalRole.ADMIN || user.getAccountStatus() != AccountStatus.ACTIVE) {
throw new IllegalArgumentException("An active Admin is required");
}
return user.getId();
}
private static AccountIdentity identity(AppUser user) {
return new AccountIdentity(
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
}
}
@@ -0,0 +1,73 @@
package com.lab.labtimesheet.feature.account.service;
import java.time.Clock;
import java.util.Locale;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BootstrapService {
private final SystemStateRepository systemStates;
private final AppUserRepository users;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
Clock clock) {
this.systemStates = systemStates;
this.users = users;
this.passwords = passwords;
this.clock = clock;
}
@Transactional
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
String normalizedEmail = normalizeEmail(email);
String normalizedName = requireText(displayName, "Display name");
requirePassword(password);
SystemState state = systemStates.findSingletonForUpdate()
.orElseThrow(() -> new IllegalStateException("System state is missing"));
if (state.isInitialized()) {
return BootstrapOutcome.ALREADY_INITIALIZED;
}
var now = clock.instant();
AppUser admin = users.save(AppUser.bootstrapAdmin(
normalizedEmail, normalizedName, passwords.encode(password), now));
state.initialize(admin, now);
return BootstrapOutcome.CREATED;
}
@Transactional(readOnly = true)
public boolean isInitialized() {
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
}
public static String normalizeEmail(String email) {
return requireText(email, "Email").toLowerCase(Locale.ROOT);
}
public static void requirePassword(String password) {
if (password == null || password.length() < 12 || password.length() > 128) {
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
}
}
private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " is required");
}
return value.trim();
}
public enum BootstrapOutcome {
CREATED,
ALREADY_INITIALIZED
}
}
@@ -0,0 +1,32 @@
package com.lab.labtimesheet.feature.account.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var account = users.findByNormalizedEmail(BootstrapService.normalizeEmail(username))
.orElseThrow(() -> new UsernameNotFoundException("Invalid credentials"));
String hash = account.getPasswordHash();
return User.withUsername(account.getEmail())
.password(hash == null ? "{noop}unavailable" : hash)
.roles(account.getGlobalRole().name())
.disabled(account.getAccountStatus() != AccountStatus.ACTIVE)
.build();
}
}
@@ -1,10 +1,11 @@
package com.lab.labtimesheet.configuration; package com.lab.labtimesheet.feature.integration.controller;
import java.security.Principal; import java.security.Principal;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import org.springframework.jdbc.core.JdbcTemplate; 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.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -15,11 +16,11 @@ import org.springframework.web.bind.annotation.RequestParam;
@RequestMapping("/admin/smtp") @RequestMapping("/admin/smtp")
class SmtpController { class SmtpController {
private final SmtpConfigurationService smtp; private final SmtpConfigurationService smtp;
private final JdbcTemplate jdbc; private final AccountService accounts;
SmtpController(SmtpConfigurationService smtp, JdbcTemplate jdbc) { SmtpController(SmtpConfigurationService smtp, AccountService accounts) {
this.smtp = smtp; this.smtp = smtp;
this.jdbc = jdbc; this.accounts = accounts;
} }
@GetMapping @GetMapping
@@ -49,7 +50,6 @@ class SmtpController {
} }
private long adminId(Principal principal) { private long adminId(Principal principal) {
return jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class, return accounts.requireActiveAdminId(principal.getName());
principal.getName());
} }
} }
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model;
public enum SecurityMode {
NONE,
STARTTLS,
TLS
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model;
public enum SmtpStatus {
DRAFT,
ACTIVE,
RETIRED
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.integration.model.dto;
public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
public EncryptedSecret {
ciphertext = ciphertext.clone();
nonce = nonce.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
@Override
public byte[] nonce() {
return nonce.clone();
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model.dto;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model.dto;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
@@ -0,0 +1,198 @@
package com.lab.labtimesheet.feature.integration.model.entity;
import java.time.Instant;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "smtp_configurations")
public class SmtpConfiguration {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private SmtpStatus status;
@Column(nullable = false, length = 255)
private String host;
@Column(nullable = false)
private int port;
@Enumerated(EnumType.STRING)
@Column(name = "security_mode", nullable = false, length = 16)
private SecurityMode securityMode;
@Column(length = 320)
private String username;
@Column(name = "password_ciphertext")
private byte[] passwordCiphertext;
@Column(name = "password_nonce")
private byte[] passwordNonce;
@Column(name = "secret_key_version")
private Integer secretKeyVersion;
@Column(name = "from_address", nullable = false, length = 320)
private String fromAddress;
@Column(name = "from_name", nullable = false, length = 120)
private String fromName;
@Column(name = "tested_at")
private Instant testedAt;
@Column(name = "tested_by_user_id")
private Long testedByUserId;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "activated_by_user_id")
private Long activatedByUserId;
@Column(name = "retired_at")
private Instant retiredAt;
@Column(name = "retired_by_user_id")
private Long retiredByUserId;
@Column(name = "created_by_user_id", nullable = false)
private Long createdByUserId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected SmtpConfiguration() {
}
public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) {
SmtpConfiguration configuration = new SmtpConfiguration();
configuration.status = SmtpStatus.DRAFT;
configuration.createdByUserId = adminId;
configuration.createdAt = now;
configuration.updateDraft(draft, password, now);
return configuration;
}
public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) {
if (status != SmtpStatus.DRAFT) {
throw new IllegalStateException("Only an SMTP draft can be edited");
}
host = draft.host().trim();
port = draft.port();
securityMode = draft.securityMode();
username = clean(draft.username());
passwordCiphertext = password == null ? null : password.ciphertext();
passwordNonce = password == null ? null : password.nonce();
secretKeyVersion = password == null ? null : password.keyVersion();
fromAddress = draft.fromAddress().trim();
fromName = draft.fromName().trim();
testedAt = null;
testedByUserId = null;
updatedAt = now;
}
public void markTested(long adminId, Instant now) {
if (status != SmtpStatus.DRAFT) {
throw new IllegalStateException("SMTP draft is no longer available");
}
testedAt = now;
testedByUserId = adminId;
updatedAt = now;
}
public void activate(long adminId, Instant now) {
if (status != SmtpStatus.DRAFT || testedAt == null) {
throw new IllegalStateException("SMTP draft must pass a test before activation");
}
status = SmtpStatus.ACTIVE;
activatedAt = now;
activatedByUserId = adminId;
updatedAt = now;
}
public void retire(long adminId, Instant now) {
if (status != SmtpStatus.ACTIVE) {
throw new IllegalStateException("Only active SMTP can be retired");
}
status = SmtpStatus.RETIRED;
retiredAt = now;
retiredByUserId = adminId;
updatedAt = now;
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public Long getId() {
return id;
}
public SmtpStatus getStatus() {
return status;
}
public String getHost() {
return host;
}
public int getPort() {
return port;
}
public SecurityMode getSecurityMode() {
return securityMode;
}
public String getUsername() {
return username;
}
public byte[] getPasswordCiphertext() {
return passwordCiphertext == null ? null : passwordCiphertext.clone();
}
public byte[] getPasswordNonce() {
return passwordNonce == null ? null : passwordNonce.clone();
}
public Integer getSecretKeyVersion() {
return secretKeyVersion;
}
public String getFromAddress() {
return fromAddress;
}
public String getFromName() {
return fromName;
}
public Instant getTestedAt() {
return testedAt;
}
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.integration.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
boolean existsByStatus(SmtpStatus status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
}
@@ -1,29 +1,29 @@
package com.lab.labtimesheet.configuration; package com.lab.labtimesheet.feature.integration.service;
import java.util.Properties; 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.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
class JavaMailSmtpProbe implements SmtpProbe { class JavaMailSmtpProbe implements SmtpProbe {
@Override @Override
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body) { public void send(SmtpConnection connection, String recipient, String subject, String body) {
JavaMailSenderImpl sender = new JavaMailSenderImpl(); JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(connection.host()); sender.setHost(connection.host());
sender.setPort(connection.port()); sender.setPort(connection.port());
sender.setUsername(connection.username()); sender.setUsername(connection.username());
sender.setPassword(connection.password()); sender.setPassword(connection.password());
Properties properties = sender.getJavaMailProperties(); Properties properties = sender.getJavaMailProperties();
if (connection.securityMode() == SmtpConfigurationService.SecurityMode.STARTTLS) { if (connection.securityMode() == SecurityMode.STARTTLS) {
properties.setProperty("mail.smtp.starttls.enable", "true"); properties.setProperty("mail.smtp.starttls.enable", "true");
properties.setProperty("mail.smtp.starttls.required", "true"); properties.setProperty("mail.smtp.starttls.required", "true");
} else if (connection.securityMode() == SmtpConfigurationService.SecurityMode.TLS) { } else if (connection.securityMode() == SecurityMode.TLS) {
sender.setProtocol("smtps"); sender.setProtocol("smtps");
} }
SimpleMailMessage message = new SimpleMailMessage(); SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(connection.fromAddress()); message.setFrom(connection.fromAddress());
message.setTo(recipient); message.setTo(recipient);
@@ -1,13 +1,14 @@
package com.lab.labtimesheet.configuration; package com.lab.labtimesheet.feature.integration.service;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.security.SecureRandom; 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.Cipher;
import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@@ -19,7 +20,7 @@ public class SecretCipher {
private final SecureRandom random = new SecureRandom(); private final SecureRandom random = new SecureRandom();
SecretCipher(SecurityProperties properties) { SecretCipher(SecurityProperties properties) {
this.key = new SecretKeySpec(properties.decodedMasterKey(), "AES"); key = new SecretKeySpec(properties.decodedMasterKey(), "AES");
} }
EncryptedSecret encrypt(String plaintext) { EncryptedSecret encrypt(String plaintext) {
@@ -43,7 +44,4 @@ public class SecretCipher {
throw new IllegalStateException("Unable to decrypt integration secret", exception); throw new IllegalStateException("Unable to decrypt integration secret", exception);
} }
} }
record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
}
} }
@@ -0,0 +1,116 @@
package com.lab.labtimesheet.feature.integration.service;
import java.time.Clock;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SmtpConfigurationService {
private final SmtpConfigurationRepository configurations;
private final AccountService accounts;
private final SecretCipher secrets;
private final SmtpProbe probe;
private final Environment environment;
private final Clock clock;
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
this.configurations = configurations;
this.accounts = accounts;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
}
@Transactional
public long saveDraft(long adminId, SmtpDraft draft) {
validate(draft);
EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
var now = clock.instant();
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
SmtpConfiguration configuration = configurations.findByStatus(SmtpStatus.DRAFT)
.map(existing -> {
existing.updateDraft(draft, password, now);
return existing;
})
.orElseGet(() -> SmtpConfiguration.draft(draft, password, verifiedAdminId, now));
return configurations.save(configuration).getId();
}
public void testDraft(long draftId, long adminId, String recipient) {
SmtpConfiguration draft = configurations.findById(draftId)
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
.orElseThrow(() -> new IllegalStateException("SMTP configuration is not available"));
probe.send(connection(draft), recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
draft.markTested(verifiedAdminId, clock.instant());
configurations.save(draft);
}
@Transactional
public void activate(long draftId, long adminId) {
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
.orElseThrow(() -> new IllegalStateException("SMTP draft must pass a test before activation"));
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
var now = clock.instant();
configurations.findByStatus(SmtpStatus.ACTIVE)
.ifPresent(active -> active.retire(verifiedAdminId, now));
draft.activate(verifiedAdminId, now);
}
@Transactional(readOnly = true)
public boolean hasActiveConfiguration() {
return configurations.existsByStatus(SmtpStatus.ACTIVE);
}
@Transactional(readOnly = true)
public SmtpConnection activeConnection() {
return configurations.findByStatus(SmtpStatus.ACTIVE)
.map(this::connection)
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
}
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
}
private SmtpConnection connection(SmtpConfiguration configuration) {
byte[] ciphertext = configuration.getPasswordCiphertext();
return new SmtpConnection(
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
configuration.getUsername(),
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
configuration.getFromAddress(), configuration.getFromName());
}
private void validate(SmtpDraft draft) {
if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535
|| draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank()
|| draft.fromName() == null || draft.fromName().isBlank()) {
throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required");
}
if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) {
throw new IllegalArgumentException("SMTP username and password must be supplied together");
}
if (draft.securityMode() == SecurityMode.NONE
&& !environment.acceptsProfiles(Profiles.of("dev", "test"))) {
throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test");
}
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.integration.service;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
@FunctionalInterface
public interface SmtpProbe {
void send(SmtpConnection connection, String recipient, String subject, String body);
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.notifications;
/** Notifications module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.projects;
/** Projects and tasks module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.reporting;
/** Reporting module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -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")
@@ -1,17 +0,0 @@
package com.lab.labtimesheet;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
abstract class PlatformDatabaseTestSupport {
@Autowired
protected JdbcTemplate jdbc;
@BeforeEach
void resetPlatformData() {
jdbc.execute("TRUNCATE smtp_configurations, user_action_tokens, intern_profiles, app_users RESTART IDENTITY CASCADE");
jdbc.update("insert into system_state (singleton_id) values (1)");
}
}
@@ -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,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);
@@ -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
@@ -1,4 +1,4 @@
package com.lab.labtimesheet; package com.lab.labtimesheet.feature.account.service;
import static org.assertj.core.api.Assertions.assertThat; 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.request.MockMvcRequestBuilders.get;
@@ -10,28 +10,42 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import com.lab.labtimesheet.accounts.BootstrapService; import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome; 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.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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@Import(TestcontainersConfiguration.class) @Import(TestcontainersConfiguration.class)
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("test") @ActiveProfiles("test")
class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BootstrapIntegrationTest {
@Autowired @Autowired
private BootstrapService bootstrapService; private BootstrapService bootstrapService;
@Autowired
private AccountService accountService;
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@Autowired
private AppUserRepository users;
@Autowired
private SystemStateRepository systemStates;
@Test @Test
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception { void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk()); mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
@@ -46,7 +60,7 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception { void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception {
CountDownLatch ready = new CountDownLatch(2); CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1); CountDownLatch start = new CountDownLatch(1);
List<Future<BootstrapOutcome>> futures = new ArrayList<>(); List<Future<BootstrapService.BootstrapOutcome>> futures = new ArrayList<>();
try (var executor = Executors.newFixedThreadPool(2)) { try (var executor = Executors.newFixedThreadPool(2)) {
for (int i = 0; i < 2; i++) { for (int i = 0; i < 2; i++) {
@@ -63,15 +77,20 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
} }
assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder( assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder(
BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED); BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1); assertThat(users.count()).isEqualTo(1);
assertThat(jdbc.queryForObject( assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1);
"select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'", var createdUser = users.findAll().getFirst();
Integer.class)).isEqualTo(1); var identityByEmail = accountService.requireIdentityByEmail(" " + createdUser.getEmail().toUpperCase() + " ");
assertThat(identityByEmail.email()).isEqualTo(createdUser.getEmail());
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(bootstrapService.bootstrap( assertThat(bootstrapService.bootstrap(
"another@example.com", "Another", "correct horse battery staple")) "another@example.com", "Another", "correct horse battery staple"))
.isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED); .isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class)) assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
.isTrue();
} }
} }
@@ -1,14 +1,18 @@
package com.lab.labtimesheet; package com.lab.labtimesheet.feature.integration.service;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import com.lab.labtimesheet.accounts.BootstrapService;
import com.lab.labtimesheet.configuration.SmtpConfigurationService; import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; import com.lab.labtimesheet.feature.account.service.BootstrapService;
import com.lab.labtimesheet.configuration.SmtpProbe; 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.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;
@@ -21,46 +25,47 @@ import org.springframework.test.context.ActiveProfiles;
@Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class}) @Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class})
@SpringBootTest @SpringBootTest
@ActiveProfiles("test") @ActiveProfiles("test")
class SmtpIntegrationTest extends PlatformDatabaseTestSupport { class SmtpIntegrationTest {
@Autowired @Autowired
private BootstrapService bootstrapService; private BootstrapService bootstrapService;
@Autowired
private AccountService accountService;
@Autowired @Autowired
private SmtpConfigurationService smtpService; private SmtpConfigurationService smtpService;
@Autowired @Autowired
private RecordingSmtpProbe smtpProbe; private RecordingSmtpProbe smtpProbe;
@Autowired
private SmtpConfigurationRepository configurations;
@Test @Test
void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() { void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() {
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
long adminId = jdbc.queryForObject("select id from app_users", Long.class); long adminId = accountService.requireActiveAdminId("admin@example.com");
long draftId = smtpService.saveDraft(adminId, new SmtpDraft( long draftId = smtpService.saveDraft(adminId, new SmtpDraft(
"mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab")); "mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab"));
byte[] ciphertext = jdbc.queryForObject( var savedDraft = configurations.findById(draftId).orElseThrow();
"select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId); byte[] ciphertext = savedDraft.getPasswordCiphertext();
assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password"); assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password");
assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?", assertThat(savedDraft.getPasswordNonce()).hasSize(12);
Integer.class, draftId)).isEqualTo(12); assertThat(savedDraft.getSecretKeyVersion()).isEqualTo(1);
assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?",
Integer.class, draftId)).isEqualTo(1);
smtpProbe.fail = true; smtpProbe.fail = true;
assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com")) assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com"))
.isInstanceOf(IllegalStateException.class); .isInstanceOf(IllegalStateException.class);
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.DRAFT);
.isEqualTo("DRAFT"); assertThat(configurations.findById(draftId).orElseThrow().getTestedAt()).isNull();
assertThat(jdbc.queryForObject("select tested_at is null from smtp_configurations where id = ?", Boolean.class,
draftId)).isTrue();
assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class); assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class);
smtpProbe.fail = false; smtpProbe.fail = false;
smtpService.testDraft(draftId, adminId, "admin@example.com"); smtpService.testDraft(draftId, adminId, "admin@example.com");
smtpService.activate(draftId, adminId); smtpService.activate(draftId, adminId);
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.ACTIVE);
.isEqualTo("ACTIVE");
} }
@TestConfiguration(proxyBeanMethods = false) @TestConfiguration(proxyBeanMethods = false)
@@ -76,7 +81,7 @@ class SmtpIntegrationTest extends PlatformDatabaseTestSupport {
private boolean fail; private boolean fail;
@Override @Override
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, public void send(SmtpConnection connection, String recipient, String subject,
String body) { String body) {
if (fail) { if (fail) {
throw new IllegalStateException("simulated SMTP failure"); throw new IllegalStateException("simulated SMTP failure");