fix account and SMTP onboarding workflows

This commit is contained in:
sechmachine
2026-08-15 02:29:41 +07:00
parent 6181984cf8
commit 17fa25bb09
20 changed files with 988 additions and 85 deletions
@@ -43,7 +43,7 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class AccountWebIntegrationTest {
@Autowired
private MockMvc mockMvc;
@@ -139,6 +139,73 @@ class AccountWebIntegrationTest {
.andExpect(unauthenticated());
}
@Test
void invalidAndDuplicateAccountFormsReturnActionableErrorsWithoutCreatingAnotherAccount() throws Exception {
mockMvc.perform(post("/admin/accounts")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("email", "not-an-email")
.param("displayName", "Safe display name")
.param("role", "INTERN")
.param("studentCode", "")
.param("internshipStart", "")
.param("internshipEnd", ""))
.andExpect(status().isOk())
.andExpect(view().name("accounts/new"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Intern details are required")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Safe display name")));
mockMvc.perform(post("/admin/accounts")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("email", "mentor@example.com")
.param("displayName", "Mentor One")
.param("role", "MENTOR"))
.andExpect(status().is3xxRedirection());
mockMvc.perform(post("/admin/accounts")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("email", " MENTOR@EXAMPLE.COM ")
.param("displayName", "Duplicate Mentor")
.param("role", "MENTOR"))
.andExpect(status().isOk())
.andExpect(view().name("accounts/new"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("already exists")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Duplicate Mentor")));
}
@Test
void additionalAdminActivatesAndAuthenticatesWithoutChangingTheFirstAdmin() throws Exception {
mockMvc.perform(post("/admin/accounts")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("email", "second-admin@example.com")
.param("displayName", "Second Admin")
.param("role", "ADMIN"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/accounts/new?created"));
String rawToken = mail.activationTokenFor("second-admin@example.com");
mockMvc.perform(post("/activate")
.with(csrf())
.param("token", rawToken)
.param("password", "new secure admin password")
.param("confirmPassword", "new secure admin password"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/login?activated"));
mockMvc.perform(post("/login")
.with(csrf())
.param("username", "second-admin@example.com")
.param("password", "new secure admin password"))
.andExpect(status().is3xxRedirection())
.andExpect(authenticated().withRoles("ADMIN"));
assertThat(accounts.requireIdentityByEmail("admin@example.com").status()).isEqualTo(AccountStatus.ACTIVE);
assertThat(accounts.requireIdentityByEmail("admin@example.com").role()).isEqualTo(GlobalRole.ADMIN);
}
@TestConfiguration(proxyBeanMethods = false)
static class MailProbeConfiguration {
@Bean
@@ -0,0 +1,140 @@
package com.lab.labtimesheet.feature.account.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
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.mock.web.MockHttpSession;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BootstrapOnboardingWebIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void bootstrapOffersSmtpAfterTheFirstAdminSignsIn() throws Exception {
MockHttpSession session = new MockHttpSession();
var bootstrapResult = mockMvc.perform(post("/bootstrap")
.session(session)
.with(csrf())
.param("email", " ADMIN@EXAMPLE.COM ")
.param("displayName", "First Admin")
.param("password", "correct horse battery staple"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp?onboarding"))
.andReturn();
assertThat(bootstrapResult.getRequest().getSession(false)).isSameAs(session);
mockMvc.perform(get("/admin/smtp?onboarding").session(session))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/login"));
mockMvc.perform(post("/login")
.session(session)
.with(csrf())
.param("username", " ADMIN@EXAMPLE.COM ")
.param("password", "correct horse battery staple"))
.andExpect(status().is3xxRedirection())
.andExpect(header().string("Location", org.hamcrest.Matchers.containsString(
"/admin/smtp?onboarding")));
mockMvc.perform(get("/admin/smtp?onboarding").with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP now")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Defer SMTP")));
}
@Test
void fiveDistinctDeferralConfirmationsAreSequentialAndOnlyTheLastCanFinish() throws Exception {
initializeAdmin();
var first = mockMvc.perform(get("/admin/smtp/defer")
.with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(view().name("smtp/defer"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Account onboarding is disabled")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Back")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("Finish without SMTP"))))
.andReturn();
MockHttpSession session = (MockHttpSession) first.getRequest().getSession(false);
assertThat(session).isNotNull();
assertStep(session, "Activation resend is disabled", false);
assertStep(session, "Password recovery is disabled", false);
assertStep(session, "Workflow email delivery is less immediate", false);
assertStep(session, "I acknowledge this installation remains restricted", true);
mockMvc.perform(post("/admin/smtp/defer/finish")
.session(session)
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/dashboard"));
}
@Test
void bootstrapValidationRetainsSafeFieldsButNeverThePassword() throws Exception {
mockMvc.perform(post("/bootstrap")
.with(csrf())
.param("email", "not-an-email")
.param("displayName", "Safe Admin Name")
.param("password", "must-not-be-rendered"))
.andExpect(status().isOk())
.andExpect(view().name("bootstrap/form"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Safe Admin Name")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("must-not-be-rendered"))));
}
private void initializeAdmin() throws Exception {
mockMvc.perform(post("/bootstrap")
.with(csrf())
.param("email", "admin@example.com")
.param("displayName", "Admin")
.param("password", "correct horse battery staple"))
.andExpect(status().is3xxRedirection());
}
private void assertStep(MockHttpSession session, String warning, boolean finishVisible) throws Exception {
mockMvc.perform(post("/admin/smtp/defer/next")
.session(session)
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp/defer"));
var matcher = finishVisible
? org.hamcrest.Matchers.containsString("Finish without SMTP")
: org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("Finish without SMTP"));
mockMvc.perform(get("/admin/smtp/defer")
.session(session)
.with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString(warning)))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Back")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP")))
.andExpect(content().string(matcher));
}
}
@@ -0,0 +1,161 @@
package com.lab.labtimesheet.feature.integration.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import java.util.ArrayList;
import java.util.List;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
import org.junit.jupiter.api.BeforeEach;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@Import({TestcontainersConfiguration.class, SmtpOnboardingWebIntegrationTest.ProbeConfiguration.class})
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class SmtpOnboardingWebIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private BootstrapService bootstrap;
@Autowired
private RecordingProbe probe;
@BeforeEach
void initializeAdmin() {
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
}
@Test
void adminCanSaveTestAndActivateSmtpWithVisibleStatus() throws Exception {
mockMvc.perform(post("/admin/smtp/draft")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("host", "mailpit")
.param("port", "1025")
.param("securityMode", "NONE")
.param("username", "smtp-user")
.param("password", "smtp-secret")
.param("fromAddress", "notifications@example.com")
.param("fromName", "Lab Timesheet"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp?saved"));
var draftPage = mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Draft saved")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Test connection")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("Activate SMTP"))))
.andReturn();
String html = draftPage.getResponse().getContentAsString();
String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1");
assertThat(draftId).matches("[0-9]+");
mockMvc.perform(post("/admin/smtp/test")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("draftId", draftId))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp?tested"));
assertThat(probe.recipients).contains("admin@example.com");
mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Test passed")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Activate SMTP")));
mockMvc.perform(post("/admin/smtp/activate")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("draftId", draftId))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp?activated"));
mockMvc.perform(get("/admin/smtp").with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("SMTP is active")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("restricted installation"))));
}
@Test
void invalidDraftRetainsOnlySafeFieldsAndRendersValidationErrors() throws Exception {
mockMvc.perform(post("/admin/smtp/draft")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("host", "")
.param("port", "70000")
.param("securityMode", "STARTTLS")
.param("username", "smtp-user")
.param("password", "must-not-be-rendered")
.param("fromAddress", "not-an-email")
.param("fromName", "Safe sender name"))
.andExpect(status().isOk())
.andExpect(view().name("smtp/form"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Host is required")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Port must be between")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Safe sender name")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("must-not-be-rendered"))));
}
@Test
void restrictedWarningPersistsOnAdminPagesUntilActivationAndMutationsRequireCsrf() throws Exception {
mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("restricted installation")));
mockMvc.perform(post("/admin/smtp/draft")
.with(user("admin@example.com").roles("ADMIN"))
.param("host", "mailpit")
.param("port", "1025")
.param("securityMode", "NONE")
.param("fromAddress", "admin@example.com")
.param("fromName", "Lab Timesheet"))
.andExpect(status().isForbidden());
}
@TestConfiguration(proxyBeanMethods = false)
static class ProbeConfiguration {
@Bean
@Primary
RecordingProbe recordingProbe() {
return new RecordingProbe();
}
}
static final class RecordingProbe implements SmtpProbe {
private final List<String> recipients = new ArrayList<>();
@Override
public void send(SmtpConnection connection, String recipient, String subject, String body) {
recipients.add(recipient);
}
}
}