From bc70db1d0d8eaa68bb8e22db44e38af27b0fa945 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:46:09 +0700 Subject: [PATCH 1/3] feat: add bootstrap and SMTP onboarding --- .../integration/first-admin-bootstrap.md | 74 +++++++ docs/tests/integration/smtp-onboarding.md | 75 +++++++ pom.xml | 4 + .../labtimesheet/LabtimesheetApplication.java | 4 + .../accounts/BootstrapAccessFilter.java | 34 ++++ .../accounts/BootstrapController.java | 46 +++++ .../accounts/BootstrapService.java | 82 ++++++++ .../labtimesheet/accounts/HomeController.java | 12 ++ .../accounts/JdbcUserDetailsService.java | 37 ++++ .../accounts/SecurityConfiguration.java | 38 ++++ .../configuration/JavaMailSmtpProbe.java | 34 ++++ .../configuration/SecretCipher.java | 49 +++++ .../configuration/SecurityProperties.java | 29 +++ .../SmtpConfigurationService.java | 183 ++++++++++++++++++ .../configuration/SmtpController.java | 55 ++++++ .../labtimesheet/configuration/SmtpProbe.java | 6 + src/main/resources/application-dev.yaml | 4 + .../resources/templates/bootstrap/form.html | 16 ++ src/main/resources/templates/home.html | 5 + src/main/resources/templates/smtp/form.html | 19 ++ .../BootstrapIntegrationTest.java | 77 ++++++++ .../PlatformDatabaseTestSupport.java | 17 ++ .../lab/labtimesheet/SmtpIntegrationTest.java | 86 ++++++++ 23 files changed, 986 insertions(+) create mode 100644 docs/tests/integration/first-admin-bootstrap.md create mode 100644 docs/tests/integration/smtp-onboarding.md create mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/HomeController.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java create mode 100644 src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpController.java create mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java create mode 100644 src/main/resources/templates/bootstrap/form.html create mode 100644 src/main/resources/templates/home.html create mode 100644 src/main/resources/templates/smtp/form.html create mode 100644 src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java create mode 100644 src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java create mode 100644 src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md new file mode 100644 index 0000000..c722108 --- /dev/null +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -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.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. + +## 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. + +## 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,SmtpAccountIntegrationTest 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: 7, 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. diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md new file mode 100644 index 0000000..3b411f2 --- /dev/null +++ b/docs/tests/integration/smtp-onboarding.md @@ -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.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 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=BootstrapIntegrationTest,SmtpAccountIntegrationTest 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: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +./mvnw test +Tests run: 7, 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. diff --git a/pom.xml b/pom.xml index bcf4400..4002dca 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,10 @@ 25 + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java index 33a58b6..317bb73 100644 --- a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java +++ b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java @@ -2,8 +2,12 @@ package com.lab.labtimesheet; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +import com.lab.labtimesheet.configuration.SecurityProperties; @SpringBootApplication +@EnableConfigurationProperties(SecurityProperties.class) public class LabtimesheetApplication { public static void main(String[] args) { diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java new file mode 100644 index 0000000..816f8c4 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java @@ -0,0 +1,34 @@ +package com.lab.labtimesheet.accounts; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +class BootstrapAccessFilter extends OncePerRequestFilter { + private final BootstrapService bootstrap; + + 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"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java new file mode 100644 index 0000000..d6ae88e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java @@ -0,0 +1,46 @@ +package com.lab.labtimesheet.accounts; + +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); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java new file mode 100644 index 0000000..42dbc58 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java @@ -0,0 +1,82 @@ +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 + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/HomeController.java b/src/main/java/com/lab/labtimesheet/accounts/HomeController.java new file mode 100644 index 0000000..4cc0e94 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/HomeController.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.accounts; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +class HomeController { + @GetMapping("/") + String home() { + return "home"; + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java b/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java new file mode 100644 index 0000000..3ef4067 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java @@ -0,0 +1,37 @@ +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); + } +} diff --git a/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java new file mode 100644 index 0000000..4c85628 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java @@ -0,0 +1,38 @@ +package com.lab.labtimesheet.accounts; + +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(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java b/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java new file mode 100644 index 0000000..797d707 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java @@ -0,0 +1,34 @@ +package com.lab.labtimesheet.configuration; + +import java.util.Properties; + +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(SmtpConfigurationService.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() == SmtpConfigurationService.SecurityMode.STARTTLS) { + properties.setProperty("mail.smtp.starttls.enable", "true"); + properties.setProperty("mail.smtp.starttls.required", "true"); + } else if (connection.securityMode() == SmtpConfigurationService.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); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java b/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java new file mode 100644 index 0000000..25433e7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java @@ -0,0 +1,49 @@ +package com.lab.labtimesheet.configuration; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; + +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) { + this.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); + } + } + + record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) { + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java b/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java new file mode 100644 index 0000000..a4cdace --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java @@ -0,0 +1,29 @@ +package com.lab.labtimesheet.configuration; + +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; + } + + 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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java new file mode 100644 index 0000000..89387f0 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java @@ -0,0 +1,183 @@ +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) { + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java new file mode 100644 index 0000000..4b825c5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java @@ -0,0 +1,55 @@ +package com.lab.labtimesheet.configuration; + +import java.security.Principal; + +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; +import org.springframework.jdbc.core.JdbcTemplate; +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 JdbcTemplate jdbc; + + SmtpController(SmtpConfigurationService smtp, JdbcTemplate jdbc) { + this.smtp = smtp; + this.jdbc = jdbc; + } + + @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 jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class, + principal.getName()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java new file mode 100644 index 0000000..df20793 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java @@ -0,0 +1,6 @@ +package com.lab.labtimesheet.configuration; + +@FunctionalInterface +public interface SmtpProbe { + void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body); +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 765f81d..062ef03 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -6,3 +6,7 @@ spring: mail: host: ${LAB_SMTP_HOST:localhost} port: ${LAB_SMTP_PORT:1025} +lab: + security: + # Explicit non-production key; production must supply its own 256-bit key. + master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= diff --git a/src/main/resources/templates/bootstrap/form.html b/src/main/resources/templates/bootstrap/form.html new file mode 100644 index 0000000..cbc74a0 --- /dev/null +++ b/src/main/resources/templates/bootstrap/form.html @@ -0,0 +1,16 @@ + + +Initialize Lab Timesheet + +
+

Create the first administrator

+

+
+ + + + +
+
+ + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..8f23b78 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,5 @@ + + +Lab Timesheet +

Lab Timesheet

+ diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html new file mode 100644 index 0000000..8117d4b --- /dev/null +++ b/src/main/resources/templates/smtp/form.html @@ -0,0 +1,19 @@ + + +SMTP configuration + +
+

SMTP configuration

+
+ + + + + + + + +
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java new file mode 100644 index 0000000..cbb750b --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java @@ -0,0 +1,77 @@ +package com.lab.labtimesheet; + +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.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.accounts.BootstrapService; +import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome; +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.web.servlet.MockMvc; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { + + @Autowired + private BootstrapService bootstrapService; + + @Autowired + private MockMvc mockMvc; + + @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> 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( + BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject( + "select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'", + Integer.class)).isEqualTo(1); + assertThat(bootstrapService.bootstrap( + "another@example.com", "Another", "correct horse battery staple")) + .isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class)) + .isTrue(); + } +} diff --git a/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java b/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java new file mode 100644 index 0000000..e45e249 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java @@ -0,0 +1,17 @@ +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)"); + } +} diff --git a/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java b/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java new file mode 100644 index 0000000..67708ad --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java @@ -0,0 +1,86 @@ +package com.lab.labtimesheet; + +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.accounts.BootstrapService; +import com.lab.labtimesheet.configuration.SmtpConfigurationService; +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; +import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; +import com.lab.labtimesheet.configuration.SmtpProbe; +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 extends PlatformDatabaseTestSupport { + + @Autowired + private BootstrapService bootstrapService; + + @Autowired + private SmtpConfigurationService smtpService; + + @Autowired + private RecordingSmtpProbe smtpProbe; + + @Test + void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() { + bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + long adminId = jdbc.queryForObject("select id from app_users", Long.class); + long draftId = smtpService.saveDraft(adminId, new SmtpDraft( + "mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab")); + + byte[] ciphertext = jdbc.queryForObject( + "select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId); + assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password"); + assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?", + Integer.class, draftId)).isEqualTo(12); + assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?", + Integer.class, draftId)).isEqualTo(1); + smtpProbe.fail = true; + assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com")) + .isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) + .isEqualTo("DRAFT"); + 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); + + smtpProbe.fail = false; + smtpService.testDraft(draftId, adminId, "admin@example.com"); + smtpService.activate(draftId, adminId); + + assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) + .isEqualTo("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(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, + String body) { + if (fail) { + throw new IllegalStateException("simulated SMTP failure"); + } + } + } +} From 3fdfbb2bf2886da5ad63c09226204a0161801d46 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:14:21 +0700 Subject: [PATCH 2/3] refactor: adopt feature package boundaries and JPA --- .../integration/first-admin-bootstrap.md | 10 +- docs/tests/integration/platform-foundation.md | 10 +- docs/tests/integration/smtp-onboarding.md | 8 +- .../unit/package-by-feature-structure.md | 76 +++++++ .../labtimesheet/LabtimesheetApplication.java | 2 +- .../accounts/BootstrapService.java | 82 -------- .../accounts/JdbcUserDetailsService.java | 37 ---- .../labtimesheet/accounts/ModuleBoundary.java | 7 - .../attendance/ModuleBoundary.java | 7 - .../SecurityConfiguration.java | 5 +- .../SecurityProperties.java | 4 +- .../TimeConfiguration.java | 3 +- .../configuration/ModuleBoundary.java | 7 - .../SmtpConfigurationService.java | 183 ---------------- .../labtimesheet/configuration/SmtpProbe.java | 6 - .../controller}/BootstrapAccessFilter.java | 7 +- .../controller}/BootstrapController.java | 3 +- .../account/controller}/HomeController.java | 2 +- .../feature/account/model/AccountStatus.java | 8 + .../feature/account/model/GlobalRole.java | 7 + .../account/model/InternshipStatus.java | 8 + .../account/model/dto/AccountIdentity.java | 12 ++ .../feature/account/model/entity/AppUser.java | 103 +++++++++ .../account/model/entity/InternProfile.java | 61 ++++++ .../account/model/entity/SystemState.java | 57 +++++ .../account/repository/AppUserRepository.java | 17 ++ .../repository/InternProfileRepository.java | 9 + .../repository/SystemStateRepository.java | 15 ++ .../account/service/AccountService.java | 78 +++++++ .../account/service/BootstrapService.java | 73 +++++++ .../service/DatabaseUserDetailsService.java | 32 +++ .../controller}/SmtpController.java | 18 +- .../integration/model/SecurityMode.java | 7 + .../feature/integration/model/SmtpStatus.java | 7 + .../model/dto/EncryptedSecret.java | 18 ++ .../integration/model/dto/SmtpConnection.java | 7 + .../integration/model/dto/SmtpDraft.java | 7 + .../model/entity/SmtpConfiguration.java | 198 ++++++++++++++++++ .../SmtpConfigurationRepository.java | 18 ++ .../service}/JavaMailSmtpProbe.java | 12 +- .../integration/service}/SecretCipher.java | 10 +- .../service/SmtpConfigurationService.java | 116 ++++++++++ .../integration/service/SmtpProbe.java | 8 + .../notifications/ModuleBoundary.java | 7 - .../labtimesheet/projects/ModuleBoundary.java | 7 - .../reporting/ModuleBoundary.java | 7 - .../LabtimesheetApplicationTests.java | 2 + .../PlatformDatabaseTestSupport.java | 17 -- .../TestLabtimesheetApplication.java | 2 + .../config/LayerStructureTest.java | 83 ++++++++ .../{ => config}/PlatformFoundationTest.java | 17 +- .../TestcontainersConfiguration.java | 4 +- .../service}/BootstrapIntegrationTest.java | 45 ++-- .../service}/SmtpIntegrationTest.java | 47 +++-- 54 files changed, 1137 insertions(+), 466 deletions(-) create mode 100644 docs/tests/unit/package-by-feature-structure.md delete mode 100644 src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java delete mode 100644 src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java delete mode 100644 src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java rename src/main/java/com/lab/labtimesheet/{accounts => config}/SecurityConfiguration.java (89%) rename src/main/java/com/lab/labtimesheet/{configuration => config}/SecurityProperties.java (90%) rename src/main/java/com/lab/labtimesheet/{configuration => config}/TimeConfiguration.java (86%) delete mode 100644 src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java delete mode 100644 src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java rename src/main/java/com/lab/labtimesheet/{accounts => feature/account/controller}/BootstrapAccessFilter.java (80%) rename src/main/java/com/lab/labtimesheet/{accounts => feature/account/controller}/BootstrapController.java (92%) rename src/main/java/com/lab/labtimesheet/{accounts => feature/account/controller}/HomeController.java (79%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java rename src/main/java/com/lab/labtimesheet/{configuration => feature/integration/controller}/SmtpController.java (73%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java rename src/main/java/com/lab/labtimesheet/{configuration => feature/integration/service}/JavaMailSmtpProbe.java (70%) rename src/main/java/com/lab/labtimesheet/{configuration => feature/integration/service}/SecretCipher.java (86%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java delete mode 100644 src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java delete mode 100644 src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java delete mode 100644 src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java create mode 100644 src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java rename src/test/java/com/lab/labtimesheet/{ => config}/PlatformFoundationTest.java (69%) rename src/test/java/com/lab/labtimesheet/{ => config}/TestcontainersConfiguration.java (91%) rename src/test/java/com/lab/labtimesheet/{ => feature/account/service}/BootstrapIntegrationTest.java (56%) rename src/test/java/com/lab/labtimesheet/{ => feature/integration/service}/SmtpIntegrationTest.java (60%) diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md index c722108..156e350 100644 --- a/docs/tests/integration/first-admin-bootstrap.md +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -3,16 +3,16 @@ - **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.BootstrapIntegrationTest` +- **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. +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. 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 @@ -26,7 +26,7 @@ Two simultaneous submissions produce one `CREATED`, one `ALREADY_INITIALIZED`, o 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 +./mvnw -Dtest=BootstrapIntegrationTest test ``` **Observed result** @@ -53,7 +53,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/platform-foundation.md b/docs/tests/integration/platform-foundation.md index c7ebe09..104382f 100644 --- a/docs/tests/integration/platform-foundation.md +++ b/docs/tests/integration/platform-foundation.md @@ -3,16 +3,16 @@ - **Test type:** Integration - **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` -- **Test class/method:** `com.lab.labtimesheet.PlatformFoundationTest` +- **Test class/method:** `com.lab.labtimesheet.config.PlatformFoundationTest.flywayCreatesApprovedPostgresCatalog`, `com.lab.labtimesheet.config.PlatformFoundationTest.testClockIsDeterministic` - **Implementation commit:** `this milestone commit` ## Protected behavior -The application starts with the six required package boundaries, Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database. +Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database. Package structure is protected separately by `LayerStructureTest`. ## Test method -A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC catalog queries independently count application tables and foreign keys and inspect the seed. Class loading checks the declared package boundaries, and the injected test `Clock` is asserted exactly. +A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC is used only in this schema/catalog verification test to independently count application tables and foreign keys and inspect the seed. The injected test `Clock` is asserted exactly. ## Hand-derived expected result @@ -55,7 +55,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ```text Successfully applied 1 migration to schema "public", now at version v1 -Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -69,7 +69,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md index 3b411f2..7bc8955 100644 --- a/docs/tests/integration/smtp-onboarding.md +++ b/docs/tests/integration/smtp-onboarding.md @@ -3,7 +3,7 @@ - **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.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted` +- **Test class/method:** `com.lab.labtimesheet.feature.integration.service.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted` - **Implementation commit:** `this milestone commit` ## Protected behavior @@ -12,7 +12,7 @@ SMTP credentials are AES-256-GCM encrypted, only a successfully tested draft can ## 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 @@ -26,7 +26,7 @@ Ciphertext must not contain the submitted password. Failure leaves `status=DRAFT 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 +./mvnw -Dtest=SmtpIntegrationTest test ``` **Observed result** @@ -54,7 +54,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/unit/package-by-feature-structure.md b/docs/tests/unit/package-by-feature-structure.md new file mode 100644 index 0000000..bca5c91 --- /dev/null +++ b/docs/tests/unit/package-by-feature-structure.md @@ -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: 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. diff --git a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java index 317bb73..851a85c 100644 --- a/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java +++ b/src/main/java/com/lab/labtimesheet/LabtimesheetApplication.java @@ -4,7 +4,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import com.lab.labtimesheet.configuration.SecurityProperties; +import com.lab.labtimesheet.config.SecurityProperties; @SpringBootApplication @EnableConfigurationProperties(SecurityProperties.class) diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java b/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java deleted file mode 100644 index 42dbc58..0000000 --- a/src/main/java/com/lab/labtimesheet/accounts/BootstrapService.java +++ /dev/null @@ -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 - } -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java b/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java deleted file mode 100644 index 3ef4067..0000000 --- a/src/main/java/com/lab/labtimesheet/accounts/JdbcUserDetailsService.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java deleted file mode 100644 index 491317a..0000000 --- a/src/main/java/com/lab/labtimesheet/accounts/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.accounts; - -/** Accounts and security module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java deleted file mode 100644 index 4dd3875..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.attendance; - -/** Attendance, leave, and corrections module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java rename to src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index 4c85628..568b833 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -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.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -10,7 +12,6 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter; @Configuration(proxyBeanMethods = false) class SecurityConfiguration { - @Bean PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java similarity index 90% rename from src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java rename to src/main/java/com/lab/labtimesheet/config/SecurityProperties.java index a4cdace..9b00897 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/SecurityProperties.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityProperties.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.config; import java.util.Base64; @@ -16,7 +16,7 @@ public class SecurityProperties { this.masterKey = masterKey; } - byte[] decodedMasterKey() { + public byte[] decodedMasterKey() { if (masterKey == null || masterKey.isBlank()) { throw new IllegalStateException("lab.security.master-key is required"); } diff --git a/src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java similarity index 86% rename from src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java rename to src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java index 78baeb7..aba4a15 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/TimeConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/TimeConfiguration.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.config; import java.time.Clock; @@ -7,7 +7,6 @@ import org.springframework.context.annotation.Configuration; @Configuration(proxyBeanMethods = false) class TimeConfiguration { - @Bean Clock applicationClock() { return Clock.systemUTC(); diff --git a/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java deleted file mode 100644 index a5e60ca..0000000 --- a/src/main/java/com/lab/labtimesheet/configuration/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.configuration; - -/** Configuration, integrations, and calendar module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java deleted file mode 100644 index 89387f0..0000000 --- a/src/main/java/com/lab/labtimesheet/configuration/SmtpConfigurationService.java +++ /dev/null @@ -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) { - } -} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java deleted file mode 100644 index df20793..0000000 --- a/src/main/java/com/lab/labtimesheet/configuration/SmtpProbe.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.lab.labtimesheet.configuration; - -@FunctionalInterface -public interface SmtpProbe { - void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body); -} diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java similarity index 80% rename from src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java rename to src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java index 816f8c4..a7afe85 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/BootstrapAccessFilter.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java @@ -1,17 +1,18 @@ -package com.lab.labtimesheet.accounts; +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; -class BootstrapAccessFilter extends OncePerRequestFilter { +public class BootstrapAccessFilter extends OncePerRequestFilter { private final BootstrapService bootstrap; - BootstrapAccessFilter(BootstrapService bootstrap) { + public BootstrapAccessFilter(BootstrapService bootstrap) { this.bootstrap = bootstrap; } diff --git a/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java similarity index 92% rename from src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java rename to src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java index d6ae88e..0daa0dc 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/BootstrapController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapController.java @@ -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.stereotype.Controller; import org.springframework.ui.Model; diff --git a/src/main/java/com/lab/labtimesheet/accounts/HomeController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java similarity index 79% rename from src/main/java/com/lab/labtimesheet/accounts/HomeController.java rename to src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java index 4cc0e94..ac6a4e5 100644 --- a/src/main/java/com/lab/labtimesheet/accounts/HomeController.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/HomeController.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.accounts; +package com.lab.labtimesheet.feature.account.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java new file mode 100644 index 0000000..a62ff21 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/AccountStatus.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.account.model; + +public enum AccountStatus { + PENDING_ACTIVATION, + ACTIVE, + LOCKED, + DEACTIVATED +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java new file mode 100644 index 0000000..defdc1c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/GlobalRole.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.account.model; + +public enum GlobalRole { + ADMIN, + MENTOR, + INTERN +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java new file mode 100644 index 0000000..fa86482 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/InternshipStatus.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.account.model; + +public enum InternshipStatus { + NOT_STARTED, + ACTIVE, + COMPLETED, + WITHDRAWN +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java new file mode 100644 index 0000000..66110f5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/dto/AccountIdentity.java @@ -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) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java new file mode 100644 index 0000000..c7f5e9a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/AppUser.java @@ -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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java new file mode 100644 index 0000000..611116b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/InternProfile.java @@ -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() { + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java new file mode 100644 index 0000000..e1ca36b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/model/entity/SystemState.java @@ -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; + } + +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java new file mode 100644 index 0000000..8097d42 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/AppUserRepository.java @@ -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 { + @Query("select u from AppUser u where lower(trim(u.email)) = :email") + Optional findByNormalizedEmail(@Param("email") String email); + + long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java new file mode 100644 index 0000000..ed79914 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -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 { + boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java new file mode 100644 index 0000000..294ee7c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/SystemStateRepository.java @@ -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 { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from SystemState s where s.singletonId = 1") + Optional findSingletonForUpdate(); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java new file mode 100644 index 0000000..c7c84c5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -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()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java new file mode 100644 index 0000000..288ca68 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/BootstrapService.java @@ -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 + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java new file mode 100644 index 0000000..0a78533 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/DatabaseUserDetailsService.java @@ -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(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java similarity index 73% rename from src/main/java/com/lab/labtimesheet/configuration/SmtpController.java rename to src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java index 4b825c5..436a746 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/SmtpController.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/controller/SmtpController.java @@ -1,10 +1,11 @@ -package com.lab.labtimesheet.configuration; +package com.lab.labtimesheet.feature.integration.controller; import java.security.Principal; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; -import org.springframework.jdbc.core.JdbcTemplate; +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; @@ -15,11 +16,11 @@ import org.springframework.web.bind.annotation.RequestParam; @RequestMapping("/admin/smtp") class SmtpController { 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.jdbc = jdbc; + this.accounts = accounts; } @GetMapping @@ -49,7 +50,6 @@ class SmtpController { } private long adminId(Principal principal) { - return jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class, - principal.getName()); + return accounts.requireActiveAdminId(principal.getName()); } } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java new file mode 100644 index 0000000..0356e10 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SecurityMode.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.integration.model; + +public enum SecurityMode { + NONE, + STARTTLS, + TLS +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java new file mode 100644 index 0000000..104b501 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/SmtpStatus.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.integration.model; + +public enum SmtpStatus { + DRAFT, + ACTIVE, + RETIRED +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java new file mode 100644 index 0000000..48cf6d6 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/EncryptedSecret.java @@ -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(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java new file mode 100644 index 0000000..e5659cc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpConnection.java @@ -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) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java new file mode 100644 index 0000000..3df5bb5 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpDraft.java @@ -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) { +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java new file mode 100644 index 0000000..738ea1f --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/model/entity/SmtpConfiguration.java @@ -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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java new file mode 100644 index 0000000..9805e59 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/repository/SmtpConfigurationRepository.java @@ -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 { + Optional findByStatus(SmtpStatus status); + + boolean existsByStatus(SmtpStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findWithLockByIdAndStatus(Long id, SmtpStatus status); +} diff --git a/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java similarity index 70% rename from src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java rename to src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java index 797d707..1783a39 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/JavaMailSmtpProbe.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/JavaMailSmtpProbe.java @@ -1,29 +1,29 @@ -package com.lab.labtimesheet.configuration; +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(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body) { + 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() == SmtpConfigurationService.SecurityMode.STARTTLS) { + if (connection.securityMode() == SecurityMode.STARTTLS) { properties.setProperty("mail.smtp.starttls.enable", "true"); properties.setProperty("mail.smtp.starttls.required", "true"); - } else if (connection.securityMode() == SmtpConfigurationService.SecurityMode.TLS) { + } else if (connection.securityMode() == SecurityMode.TLS) { sender.setProtocol("smtps"); } - SimpleMailMessage message = new SimpleMailMessage(); message.setFrom(connection.fromAddress()); message.setTo(recipient); diff --git a/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java similarity index 86% rename from src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java rename to src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java index 25433e7..033d22f 100644 --- a/src/main/java/com/lab/labtimesheet/configuration/SecretCipher.java +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SecretCipher.java @@ -1,13 +1,14 @@ -package com.lab.labtimesheet.configuration; +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 @@ -19,7 +20,7 @@ public class SecretCipher { private final SecureRandom random = new SecureRandom(); SecretCipher(SecurityProperties properties) { - this.key = new SecretKeySpec(properties.decodedMasterKey(), "AES"); + key = new SecretKeySpec(properties.decodedMasterKey(), "AES"); } EncryptedSecret encrypt(String plaintext) { @@ -43,7 +44,4 @@ public class SecretCipher { throw new IllegalStateException("Unable to decrypt integration secret", exception); } } - - record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) { - } } diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java new file mode 100644 index 0000000..d3f7e71 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpConfigurationService.java @@ -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(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java new file mode 100644 index 0000000..c96308e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/integration/service/SmtpProbe.java @@ -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); +} diff --git a/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java deleted file mode 100644 index 76fb6b7..0000000 --- a/src/main/java/com/lab/labtimesheet/notifications/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.notifications; - -/** Notifications module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java deleted file mode 100644 index 15b62e2..0000000 --- a/src/main/java/com/lab/labtimesheet/projects/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.projects; - -/** Projects and tasks module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java b/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java deleted file mode 100644 index c29e4f6..0000000 --- a/src/main/java/com/lab/labtimesheet/reporting/ModuleBoundary.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.lab.labtimesheet.reporting; - -/** Reporting module boundary. */ -public final class ModuleBoundary { - private ModuleBoundary() { - } -} diff --git a/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java b/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java index b2c6272..f82ca04 100644 --- a/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java +++ b/src/test/java/com/lab/labtimesheet/LabtimesheetApplicationTests.java @@ -5,6 +5,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import com.lab.labtimesheet.config.TestcontainersConfiguration; + @Import(TestcontainersConfiguration.class) @SpringBootTest @ActiveProfiles("test") diff --git a/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java b/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java deleted file mode 100644 index e45e249..0000000 --- a/src/test/java/com/lab/labtimesheet/PlatformDatabaseTestSupport.java +++ /dev/null @@ -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)"); - } -} diff --git a/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java b/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java index 84e9703..baa3bda 100644 --- a/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java +++ b/src/test/java/com/lab/labtimesheet/TestLabtimesheetApplication.java @@ -2,6 +2,8 @@ package com.lab.labtimesheet; import org.springframework.boot.SpringApplication; +import com.lab.labtimesheet.config.TestcontainersConfiguration; + public class TestLabtimesheetApplication { public static void main(String[] args) { diff --git a/src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java b/src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java new file mode 100644 index 0000000..4460587 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/config/LayerStructureTest.java @@ -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 APPROVED_ROOT_PACKAGES = Set.of("config", "feature"); + private static final Set APPROVED_FEATURES = Set.of( + "account", "integration", "project", "task", "attendance", "notification", "reporting"); + private static final Set 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 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 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 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 crossFeaturePersistenceImports = entries + .filter(path -> path.toString().endsWith(".java")) + .flatMap(path -> persistenceImportsFromAnotherFeature(featurePackage, path).stream()) + .toList(); + + assertThat(crossFeaturePersistenceImports).isEmpty(); + } + } + + private static List 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); + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java b/src/test/java/com/lab/labtimesheet/config/PlatformFoundationTest.java similarity index 69% rename from src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java rename to src/test/java/com/lab/labtimesheet/config/PlatformFoundationTest.java index 5a75af0..c089e8e 100644 --- a/src/test/java/com/lab/labtimesheet/PlatformFoundationTest.java +++ b/src/test/java/com/lab/labtimesheet/config/PlatformFoundationTest.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet; +package com.lab.labtimesheet.config; import static org.assertj.core.api.Assertions.assertThat; @@ -11,7 +11,6 @@ import javax.sql.DataSource; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; @@ -21,26 +20,12 @@ import org.springframework.test.context.ActiveProfiles; @ActiveProfiles("test") class PlatformFoundationTest { - @Autowired - private ApplicationContext applicationContext; - @Autowired private DataSource dataSource; @Autowired private Clock clock; - @Test - void applicationExposesRequiredModulePackages() throws ClassNotFoundException { - assertThat(applicationContext).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.accounts.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.configuration.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.projects.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.attendance.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.notifications.ModuleBoundary")).isNotNull(); - assertThat(Class.forName("com.lab.labtimesheet.reporting.ModuleBoundary")).isNotNull(); - } - @Test void flywayCreatesApprovedPostgresCatalog() { JdbcTemplate jdbc = new JdbcTemplate(dataSource); diff --git a/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java b/src/test/java/com/lab/labtimesheet/config/TestcontainersConfiguration.java similarity index 91% rename from src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java rename to src/test/java/com/lab/labtimesheet/config/TestcontainersConfiguration.java index aed46da..d074bc0 100644 --- a/src/test/java/com/lab/labtimesheet/TestcontainersConfiguration.java +++ b/src/test/java/com/lab/labtimesheet/config/TestcontainersConfiguration.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet; +package com.lab.labtimesheet.config; import java.time.Clock; import java.time.Instant; @@ -12,7 +12,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @TestConfiguration(proxyBeanMethods = false) -class TestcontainersConfiguration { +public class TestcontainersConfiguration { @Bean @ServiceConnection diff --git a/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java similarity index 56% rename from src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java rename to src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index cbb750b..e2dbfef 100644 --- a/src/test/java/com/lab/labtimesheet/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -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.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.Future; -import com.lab.labtimesheet.accounts.BootstrapService; -import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome; +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") -class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { +@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()); @@ -46,7 +60,7 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception { CountDownLatch ready = new CountDownLatch(2); CountDownLatch start = new CountDownLatch(1); - List> futures = new ArrayList<>(); + List> futures = new ArrayList<>(); try (var executor = Executors.newFixedThreadPool(2)) { for (int i = 0; i < 2; i++) { @@ -63,15 +77,20 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport { } assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder( - BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED); - assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1); - assertThat(jdbc.queryForObject( - "select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'", - Integer.class)).isEqualTo(1); + BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(users.count()).isEqualTo(1); + assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1); + var createdUser = users.findAll().getFirst(); + 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( "another@example.com", "Another", "correct horse battery staple")) - .isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED); - assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class)) - .isTrue(); + .isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); + assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue(); } } diff --git a/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/integration/service/SmtpIntegrationTest.java similarity index 60% rename from src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java rename to src/test/java/com/lab/labtimesheet/feature/integration/service/SmtpIntegrationTest.java index 67708ad..7f5db18 100644 --- a/src/test/java/com/lab/labtimesheet/SmtpIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/integration/service/SmtpIntegrationTest.java @@ -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.assertThatThrownBy; import java.nio.charset.StandardCharsets; -import com.lab.labtimesheet.accounts.BootstrapService; -import com.lab.labtimesheet.configuration.SmtpConfigurationService; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode; -import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft; -import com.lab.labtimesheet.configuration.SmtpProbe; + +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; @@ -21,46 +25,47 @@ import org.springframework.test.context.ActiveProfiles; @Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class}) @SpringBootTest @ActiveProfiles("test") -class SmtpIntegrationTest extends PlatformDatabaseTestSupport { +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 = jdbc.queryForObject("select id from app_users", Long.class); + 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")); - byte[] ciphertext = jdbc.queryForObject( - "select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId); + var savedDraft = configurations.findById(draftId).orElseThrow(); + byte[] ciphertext = savedDraft.getPasswordCiphertext(); assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password"); - assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?", - Integer.class, draftId)).isEqualTo(12); - assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?", - Integer.class, draftId)).isEqualTo(1); + 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(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) - .isEqualTo("DRAFT"); - assertThat(jdbc.queryForObject("select tested_at is null from smtp_configurations where id = ?", Boolean.class, - draftId)).isTrue(); + 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(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId)) - .isEqualTo("ACTIVE"); + assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.ACTIVE); } @TestConfiguration(proxyBeanMethods = false) @@ -76,7 +81,7 @@ class SmtpIntegrationTest extends PlatformDatabaseTestSupport { private boolean fail; @Override - public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, + public void send(SmtpConnection connection, String recipient, String subject, String body) { if (fail) { throw new IllegalStateException("simulated SMTP failure"); From 1235204bf1298599264a07943ca1167432556bd2 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:17:28 +0700 Subject: [PATCH 3/3] feat: expose account identity and eligibility boundary --- docs/tests/integration/account-boundary.md | 74 +++++++++++++++++++ .../integration/first-admin-bootstrap.md | 4 +- docs/tests/integration/platform-foundation.md | 2 +- docs/tests/integration/smtp-onboarding.md | 2 +- .../unit/package-by-feature-structure.md | 2 +- .../repository/InternProfileRepository.java | 5 ++ .../account/service/AccountService.java | 16 ++++ .../service/BootstrapIntegrationTest.java | 23 ++++-- 8 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 docs/tests/integration/account-boundary.md diff --git a/docs/tests/integration/account-boundary.md b/docs/tests/integration/account-boundary.md new file mode 100644 index 0000000..8fe5c41 --- /dev/null +++ b/docs/tests/integration/account-boundary.md @@ -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. diff --git a/docs/tests/integration/first-admin-bootstrap.md b/docs/tests/integration/first-admin-bootstrap.md index 156e350..a94a6bf 100644 --- a/docs/tests/integration/first-admin-bootstrap.md +++ b/docs/tests/integration/first-admin-bootstrap.md @@ -53,7 +53,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -63,7 +63,7 @@ BUILD SUCCESS ```text ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/platform-foundation.md b/docs/tests/integration/platform-foundation.md index 104382f..ec1b8cc 100644 --- a/docs/tests/integration/platform-foundation.md +++ b/docs/tests/integration/platform-foundation.md @@ -69,7 +69,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/integration/smtp-onboarding.md b/docs/tests/integration/smtp-onboarding.md index 7bc8955..8b7fe4b 100644 --- a/docs/tests/integration/smtp-onboarding.md +++ b/docs/tests/integration/smtp-onboarding.md @@ -64,7 +64,7 @@ BUILD SUCCESS ```text ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/docs/tests/unit/package-by-feature-structure.md b/docs/tests/unit/package-by-feature-structure.md index bca5c91..b8acc9a 100644 --- a/docs/tests/unit/package-by-feature-structure.md +++ b/docs/tests/unit/package-by-feature-structure.md @@ -67,7 +67,7 @@ export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` diff --git a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java index ed79914..b6e11e6 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/repository/InternProfileRepository.java @@ -1,9 +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 { boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status); + + boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual( + Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate); } diff --git a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java index c7c84c5..1227365 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/service/AccountService.java @@ -1,5 +1,7 @@ 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; @@ -42,6 +44,20 @@ public class AccountService { .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)) { diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index e2dbfef..d2edb50 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -4,6 +4,7 @@ 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; @@ -80,17 +81,23 @@ class BootstrapIntegrationTest { BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED); assertThat(users.count()).isEqualTo(1); assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1); - var createdUser = users.findAll().getFirst(); - 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( "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(); + } }