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:
@@ -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));
|
||||
}
|
||||
}
|
||||
+99
-1
@@ -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
|
||||
|
||||
+140
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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"));
|
||||
|
||||
+32
@@ -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");
|
||||
|
||||
+199
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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
|
||||
Reference in New Issue
Block a user