diff --git a/docs/tests/integration/account-activation.md b/docs/tests/integration/account-activation.md index cec2e4e..951613e 100644 --- a/docs/tests/integration/account-activation.md +++ b/docs/tests/integration/account-activation.md @@ -65,4 +65,4 @@ BUILD SUCCESS ## External-test boundaries -The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery or a browser following the link. MVC activation forms, resend, password reset, session invalidation, lock/deactivation, and production origin/readiness hardening remain separate Iteration 1 or later slices. +The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery. MVC creation, activation, login, role denial, and logout are covered separately by `AccountWebIntegrationTest`; resend, password reset, session invalidation after credential/state changes, lock/deactivation, and production origin/readiness hardening remain separate slices. diff --git a/docs/tests/web/account-activation-flow.md b/docs/tests/web/account-activation-flow.md new file mode 100644 index 0000000..924e103 --- /dev/null +++ b/docs/tests/web/account-activation-flow.md @@ -0,0 +1,85 @@ +# Test Evidence: Account creation, activation, authentication, and logout + +- **Test type:** Web +- **Requirement IDs:** `ACC-008–ACC-011, ACC-014, ACC-019, AUTH-001–AUTH-002, SEC-002–SEC-004` +- **Scenario IDs:** `AC-ACC-005, AC-ACC-007, AC-AUTH-001` +- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest.adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut` +- **Implementation commit:** `this milestone commit` + +## Protected behavior + +An authenticated Admin can use the account form to create pending Mentor and Intern accounts, the intended recipient can follow the emailed activation link and set a first password, normalized email login succeeds, a Mentor is denied the Admin account route, and logout clears authentication. Browser-submitted blank Intern fields do not prevent Mentor creation. + +## Test method + +MockMvc drives the production controllers, Thymeleaf templates, CSRF protection, Spring Security login/logout handlers, JPA services, and PostgreSQL 18.4. SMTP is replaced only at the network boundary by an in-memory recording probe. The test extracts the activation token from that immediate test message without logging or persisting the raw value, then exercises the public activation form. + +## Hand-derived expected result + +The Admin form returns 200. Mentor and Intern submissions redirect to `?created` and persist their immutable roles as pending accounts. Activation redirects to `/login?activated`; login with a case/whitespace variant authenticates the normalized Mentor identity. That session receives 403 at the Admin form and becomes unauthenticated after POST `/logout`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=AccountWebIntegrationTest test +``` + +**Observed result** + +```text +GET /admin/accounts/new resolved to ResourceHttpRequestHandler +Status expected:<200> but was:<404> +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +After the MVC boundary first reached GREEN, the test was tightened to submit blank Intern controls exactly as the browser form does and observed a second RED: + +```text +POST /admin/accounts returned accounts/new with +"Internship fields are allowed only for Intern accounts" +Range for response status value 200 expected: but was: +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=AccountWebIntegrationTest test +``` + +**Observed result** + +```text +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +## External-test boundaries + +This test does not contact Mailpit or an external SMTP server and is not a real browser/accessibility test. It does not cover activation resend, password reset, account lock/deactivation, session invalidation after credential/state changes, production origin configuration, containerization, CI, or deployment. diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java new file mode 100644 index 0000000..621eccf --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/AccountController.java @@ -0,0 +1,85 @@ +package com.lab.labtimesheet.feature.account.controller; + +import java.security.Principal; +import java.time.LocalDate; + +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand; +import com.lab.labtimesheet.feature.account.service.AccountService; +import org.springframework.format.annotation.DateTimeFormat; +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.RequestParam; + +@Controller +class AccountController { + private final AccountService accounts; + + AccountController(AccountService accounts) { + this.accounts = accounts; + } + + @GetMapping("/admin/accounts/new") + String newAccount() { + return "accounts/new"; + } + + @PostMapping("/admin/accounts") + String create( + @RequestParam String email, + @RequestParam String displayName, + @RequestParam GlobalRole role, + @RequestParam(required = false) String studentCode, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipStart, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipEnd, + Principal principal, + Model model) { + try { + var result = accounts.create( + new CreateAccountCommand( + email, displayName, role, clean(studentCode), internshipStart, internshipEnd), + accounts.requireActiveAdminId(principal.getName())); + return result.deliverySucceeded() + ? "redirect:/admin/accounts/new?created" + : "redirect:/admin/accounts/new?deliveryFailed"; + } catch (IllegalArgumentException | IllegalStateException exception) { + model.addAttribute("error", exception.getMessage()); + return "accounts/new"; + } + } + + private static String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + @GetMapping("/activate") + String activationForm(@RequestParam String token, Model model) { + model.addAttribute("token", token); + return "accounts/activate"; + } + + @PostMapping("/activate") + String activate( + @RequestParam String token, + @RequestParam String password, + @RequestParam String confirmPassword, + Model model) { + if (!password.equals(confirmPassword)) { + model.addAttribute("token", token); + model.addAttribute("error", "Passwords do not match"); + return "accounts/activate"; + } + try { + if (accounts.activate(token, password)) { + return "redirect:/login?activated"; + } + model.addAttribute("error", "This activation link is invalid or no longer usable"); + } catch (IllegalArgumentException exception) { + model.addAttribute("error", exception.getMessage()); + } + model.addAttribute("token", token); + return "accounts/activate"; + } +} diff --git a/src/main/resources/templates/accounts/activate.html b/src/main/resources/templates/accounts/activate.html new file mode 100644 index 0000000..69c785f --- /dev/null +++ b/src/main/resources/templates/accounts/activate.html @@ -0,0 +1,16 @@ + + +Activate account + +
+

Choose your password

+

+
+ + + + +
+
+ + diff --git a/src/main/resources/templates/accounts/new.html b/src/main/resources/templates/accounts/new.html new file mode 100644 index 0000000..8595aa5 --- /dev/null +++ b/src/main/resources/templates/accounts/new.html @@ -0,0 +1,30 @@ + + +Create account + +
+

Create account

+

Account created and activation email sent.

+

Account created, but activation delivery failed.

+

+
+ + + +
+ Intern details + + + +
+ +
+
+ + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index 8f23b78..f0d1514 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -1,5 +1,10 @@ - + Lab Timesheet -

Lab Timesheet

+ +
+

Lab Timesheet

+
+
+ diff --git a/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java new file mode 100644 index 0000000..ba040d3 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/account/controller/AccountWebIntegrationTest.java @@ -0,0 +1,173 @@ +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.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +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.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.account.service.BootstrapService; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; +import com.lab.labtimesheet.feature.integration.service.SmtpProbe; +import jakarta.servlet.http.HttpSession; +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, AccountWebIntegrationTest.MailProbeConfiguration.class}) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AccountWebIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private RecordingSmtpProbe mail; + + @BeforeEach + void initializeAdminAndSmtp() { + bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple"); + long adminId = accounts.requireActiveAdminId("admin@example.com"); + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "admin@example.com"); + smtp.activate(draftId, adminId); + mail.messages.clear(); + } + + @Test + void adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut() throws Exception { + mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/new")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Internship start"))); + + 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") + .param("studentCode", "") + .param("internshipStart", "") + .param("internshipEnd", "")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/accounts/new?created")); + + mockMvc.perform(post("/admin/accounts") + .with(user("admin@example.com").roles("ADMIN")) + .with(csrf()) + .param("email", "intern@example.com") + .param("displayName", "Intern One") + .param("role", "INTERN") + .param("studentCode", "STU-001") + .param("internshipStart", "2026-08-01") + .param("internshipEnd", "2026-12-31")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/accounts/new?created")); + + var pendingMentor = accounts.requireIdentityByEmail("mentor@example.com"); + assertThat(pendingMentor.role()).isEqualTo(GlobalRole.MENTOR); + assertThat(pendingMentor.status()).isEqualTo(AccountStatus.PENDING_ACTIVATION); + assertThat(accounts.requireIdentityByEmail("intern@example.com").role()).isEqualTo(GlobalRole.INTERN); + + String rawToken = mail.activationTokenFor("mentor@example.com"); + mockMvc.perform(get("/activate").param("token", rawToken)) + .andExpect(status().isOk()) + .andExpect(view().name("accounts/activate")); + mockMvc.perform(post("/activate") + .with(csrf()) + .param("token", rawToken) + .param("password", "new secure mentor password") + .param("confirmPassword", "new secure mentor password")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?activated")); + + var login = mockMvc.perform(post("/login") + .with(csrf()) + .param("username", " MENTOR@EXAMPLE.COM ") + .param("password", "new secure mentor password")) + .andExpect(status().is3xxRedirection()) + .andExpect(authenticated().withUsername("mentor@example.com")) + .andReturn(); + HttpSession session = login.getRequest().getSession(false); + assertThat(session).isNotNull(); + + mockMvc.perform(get("/admin/accounts/new").session((org.springframework.mock.web.MockHttpSession) session)) + .andExpect(status().isForbidden()); + mockMvc.perform(post("/logout") + .session((org.springframework.mock.web.MockHttpSession) session) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?logout")) + .andExpect(unauthenticated()); + } + + @TestConfiguration(proxyBeanMethods = false) + static class MailProbeConfiguration { + @Bean + @Primary + RecordingSmtpProbe recordingSmtpProbe() { + return new RecordingSmtpProbe(); + } + } + + static final class RecordingSmtpProbe implements SmtpProbe { + private final List messages = new ArrayList<>(); + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + messages.add(new Message(recipient, body)); + } + + String activationTokenFor(String recipient) { + String body = messages.stream() + .filter(message -> message.recipient().equals(recipient)) + .findFirst() + .orElseThrow() + .body(); + int tokenStart = body.indexOf("token="); + assertThat(tokenStart).isGreaterThanOrEqualTo(0); + return body.substring(tokenStart + "token=".length()).trim(); + } + } + + record Message(String recipient, String body) { + } +}