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
@@ -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");
}
}
}
}