feat: add bootstrap and SMTP onboarding

This commit is contained in:
sechmachine
2026-08-14 23:46:09 +07:00
parent 4b37f8fd05
commit bc70db1d0d
23 changed files with 986 additions and 0 deletions
@@ -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) {
@@ -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");
}
}
@@ -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);
}
}
}
@@ -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
}
}
@@ -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";
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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) {
}
}
@@ -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;
}
}
@@ -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) {
}
}
@@ -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());
}
}
@@ -0,0 +1,6 @@
package com.lab.labtimesheet.configuration;
@FunctionalInterface
public interface SmtpProbe {
void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body);
}
+4
View File
@@ -6,3 +6,7 @@ spring:
mail:
host: ${LAB_SMTP_HOST:localhost}
port: ${LAB_SMTP_PORT:1025}
lab:
security:
# Explicit non-production key; production must supply its own 256-bit key.
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Initialize Lab Timesheet</title></head>
<body>
<main>
<h1>Create the first administrator</h1>
<p th:if="${error}" th:text="${error}" role="alert"></p>
<form method="post" th:action="@{/bootstrap}">
<label>Email <input name="email" type="email" required autocomplete="email"></label>
<label>Display name <input name="displayName" required autocomplete="name"></label>
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<button type="submit">Create administrator</button>
</form>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Lab Timesheet</title></head>
<body><main><h1>Lab Timesheet</h1></main></body>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>SMTP configuration</title></head>
<body>
<main>
<h1>SMTP configuration</h1>
<form method="post" th:action="@{/admin/smtp/draft}">
<label>Host <input name="host" required></label>
<label>Port <input name="port" type="number" min="1" max="65535" required></label>
<label>Security <select name="securityMode"><option>STARTTLS</option><option>TLS</option><option>NONE</option></select></label>
<label>Username <input name="username" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="new-password"></label>
<label>From address <input name="fromAddress" type="email" required></label>
<label>From name <input name="fromName" required></label>
<button type="submit">Save draft</button>
</form>
</main>
</body>
</html>
@@ -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<Future<BootstrapOutcome>> futures = new ArrayList<>();
try (var executor = Executors.newFixedThreadPool(2)) {
for (int i = 0; i < 2; i++) {
int suffix = i;
futures.add(executor.submit(() -> {
ready.countDown();
start.await();
return bootstrapService.bootstrap(
"admin" + suffix + "@example.com", "First Admin", "correct horse battery staple");
}));
}
ready.await();
start.countDown();
}
assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder(
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();
}
}
@@ -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)");
}
}
@@ -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");
}
}
}
}