Merge commit '692b23e9b9891d360882671d8247965b44920b2f' into work/reports-ui

# Conflicts:
#	src/main/resources/templates/accounts/activate.html
#	src/main/resources/templates/accounts/new.html
#	src/main/resources/templates/bootstrap/form.html
This commit is contained in:
sechmachine
2026-08-15 03:32:40 +07:00
81 changed files with 2595 additions and 130 deletions
@@ -0,0 +1,55 @@
package com.lab.labtimesheet.config;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
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.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 SecurityResponseIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private BootstrapService bootstrap;
@Test
void assetsRemainPublicBeforeAndAfterBootstrap() throws Exception {
mockMvc.perform(get("/assets/review-test.css").with(anonymous()))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("asset")));
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
mockMvc.perform(get("/assets/review-test.css").with(anonymous()))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("asset")));
}
@Test
void authenticationAndActivationResponsesDoNotSendReferrers() throws Exception {
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
mockMvc.perform(get("/login"))
.andExpect(status().isOk())
.andExpect(header().string("Referrer-Policy", "no-referrer"));
mockMvc.perform(get("/activate").param("token", "non-secret-test-fixture"))
.andExpect(status().isOk())
.andExpect(header().string("Referrer-Policy", "no-referrer"));
}
}
@@ -0,0 +1,21 @@
package com.lab.labtimesheet.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class TimeConfigurationTest {
@Test
void utcInstantAtVietnamMidnightUsesTheNewLocalBusinessDate() {
Clock applicationClock = new TimeConfiguration().applicationClock();
Instant vietnamMidnight = Instant.parse("2026-08-14T17:00:00Z");
LocalDate businessDate = LocalDate.now(Clock.fixed(vietnamMidnight, applicationClock.getZone()));
assertThat(businessDate).isEqualTo(LocalDate.of(2026, 8, 15));
}
}
@@ -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,104 @@ 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 duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail() throws Exception {
mockMvc.perform(post("/admin/accounts")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("email", "first-intern@example.com")
.param("displayName", "First Intern")
.param("role", "INTERN")
.param("studentCode", "STU-ROUND-2")
.param("internshipStart", "2026-08-01")
.param("internshipEnd", "2026-12-31"))
.andExpect(status().is3xxRedirection());
mockMvc.perform(post("/admin/accounts")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("email", "second-intern@example.com")
.param("displayName", "Second Intern")
.param("role", "INTERN")
.param("studentCode", " stu-round-2 ")
.param("internshipStart", "2026-08-01")
.param("internshipEnd", "2026-12-31"))
.andExpect(status().isOk())
.andExpect(view().name("accounts/new"))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"An Intern with this student code already exists")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("this email already exists"))))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Second Intern")));
}
@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));
}
}
@@ -33,11 +33,13 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class AccountActivationIntegrationTest {
@Autowired
@@ -138,6 +140,25 @@ class AccountActivationIntegrationTest {
assertThat(summary.activeInternships()).isEqualTo(1);
}
@Test
void internshipCannotActivateBeforeItsBusinessStartDate() {
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
long adminId = accounts.requireActiveAdminId("admin@example.com");
activateSmtp(adminId);
mail.messages.clear();
var creation = accounts.create(new CreateAccountCommand(
"future-intern@example.com", "Future Intern", GlobalRole.INTERN, "STU-FUTURE",
LocalDate.of(2026, 8, 15), LocalDate.of(2026, 12, 31)), adminId);
assertThat(accounts.activate(mail.onlyActivationToken(), "future secure password")).isTrue();
assertThatThrownBy(() -> accounts.activateInternship(creation.userId(), adminId))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("start date");
assertThat(internProfiles.findById(creation.userId()).orElseThrow().getInternshipStatus())
.isEqualTo(InternshipStatus.NOT_STARTED);
}
private void activateSmtp(long adminId) {
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
@@ -11,13 +11,19 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import javax.sql.DataSource;
import com.lab.labtimesheet.LabtimesheetApplication;
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 com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
@@ -47,6 +53,9 @@ class BootstrapIntegrationTest {
@Autowired
private SystemStateRepository systemStates;
@Autowired
private DataSource dataSource;
@Test
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
@@ -87,6 +96,29 @@ class BootstrapIntegrationTest {
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
}
@Test
void bootstrapRemainsClosedInAnIndependentApplicationContext() {
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
HikariDataSource currentDataSource = (HikariDataSource) dataSource;
try (var restarted = new SpringApplicationBuilder(LabtimesheetApplication.class)
.profiles("test")
.web(WebApplicationType.SERVLET)
.properties(
"server.port=0",
"spring.main.register-shutdown-hook=false",
"spring.datasource.url=" + currentDataSource.getJdbcUrl(),
"spring.datasource.username=" + currentDataSource.getUsername(),
"spring.datasource.password=" + currentDataSource.getPassword())
.run()) {
BootstrapService restartedBootstrap = restarted.getBean(BootstrapService.class);
assertThat(restartedBootstrap.isInitialized()).isTrue();
assertThat(restartedBootstrap.bootstrap(
"another@example.com", "Another", "correct horse battery staple"))
.isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
}
}
@Test
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
@@ -0,0 +1,199 @@
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.mail.MailSendException;
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());
}
@Test
void failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft() 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("fromAddress", "notifications@example.com")
.param("fromName", "Lab Timesheet"))
.andExpect(status().is3xxRedirection());
String html = mockMvc.perform(get("/admin/smtp")
.with(user("admin@example.com").roles("ADMIN")))
.andReturn().getResponse().getContentAsString();
String draftId = html.replaceAll("(?s).*name=\"draftId\" value=\"([0-9]+)\".*", "$1");
String rawDiagnostic = "AUTH rejected for smtp-secret-raw-diagnostic";
probe.failure = new MailSendException(rawDiagnostic);
mockMvc.perform(post("/admin/smtp/test")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("draftId", draftId))
.andExpect(status().isOk())
.andExpect(view().name("smtp/form"))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"SMTP test failed. Verify the draft settings and server availability, then try again.")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString(rawDiagnostic))))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("Activate SMTP"))));
}
@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<>();
private MailSendException failure;
@Override
public void send(SmtpConnection connection, String recipient, String subject, String body) {
if (failure != null) {
throw failure;
}
recipients.add(recipient);
}
}
}
@@ -0,0 +1,58 @@
package com.lab.labtimesheet.feature.integration.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.Test;
import org.springframework.mail.javamail.JavaMailSenderImpl;
class JavaMailSmtpProbeTest {
@Test
void appliesFiniteTimeoutsAndConfiguredFromName() throws Exception {
var sender = new CapturingMailSender();
var probe = new JavaMailSmtpProbe(() -> sender);
var connection = new SmtpConnection(
"smtp.example.com", 587, SecurityMode.STARTTLS, "user", "password",
"noreply@example.com", "Lab Timesheet");
probe.send(connection, "admin@example.com", "Subject", "Body");
assertThat(sender.getJavaMailProperties())
.containsEntry("mail.smtp.connectiontimeout", "5000")
.containsEntry("mail.smtp.timeout", "5000")
.containsEntry("mail.smtp.writetimeout", "5000");
var from = (InternetAddress) sender.message.getFrom()[0];
assertThat(from.getAddress()).isEqualTo("noreply@example.com");
assertThat(from.getPersonal()).isEqualTo("Lab Timesheet");
}
@Test
void appliesFiniteTimeoutsToImplicitTlsTransport() {
var sender = new CapturingMailSender();
var probe = new JavaMailSmtpProbe(() -> sender);
var connection = new SmtpConnection(
"smtp.example.com", 465, SecurityMode.TLS, null, null,
"noreply@example.com", "Lab Timesheet");
probe.send(connection, "admin@example.com", "Subject", "Body");
assertThat(sender.getProtocol()).isEqualTo("smtps");
assertThat(sender.getJavaMailProperties())
.containsEntry("mail.smtps.connectiontimeout", "5000")
.containsEntry("mail.smtps.timeout", "5000")
.containsEntry("mail.smtps.writetimeout", "5000");
}
static final class CapturingMailSender extends JavaMailSenderImpl {
private MimeMessage message;
@Override
public void send(MimeMessage... mimeMessages) {
assertThat(mimeMessages).hasSize(1);
message = mimeMessages[0];
}
}
}
@@ -0,0 +1 @@
asset