feat(account): add project login page
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
# Test Evidence: Project-owned login flow
|
||||||
|
|
||||||
|
- **Test type:** Web
|
||||||
|
- **Requirement IDs:** `ACC-009, SEC-001, SEC-005, I1-UI-04`
|
||||||
|
- **Scenario IDs:** `I1-UI-04 authentication integration follow-up`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout`
|
||||||
|
- **Implementation commit:** `this milestone commit`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
After bootstrap, GET `/login` renders the project's `accounts/login` Thymeleaf view rather than Spring Security's generated page. Invalid credentials remain unauthenticated with generic feedback, a case-and-whitespace variant of the account email authenticates successfully, and POST `/logout` clears the authenticated session. Existing CSRF-protected form processing and server-side authorization remain enabled.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
MockMvc drives the production Spring Security filter chain, account-backed `UserDetailsService`, Thymeleaf view resolution, CSRF handling, session authentication, logout handler, JPA persistence, and PostgreSQL 18.4. The test creates only the first Admin through the production bootstrap service; no authentication component is mocked.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
GET `/login` returns 200 with view name `accounts/login` and a POST form targeting `/login`. A wrong password redirects to `/login?error` without authentication and the rendered page shows the same generic error. Login with ` ADMIN@EXAMPLE.COM ` and the correct password redirects to `/`, stores normalized username `admin@example.com`, and logout redirects to `/login?logout`, clears authentication, and renders a signed-out message.
|
||||||
|
|
||||||
|
## 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=AuthenticationWebIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /login returned Spring Security's generated HTML with no ModelAndView.
|
||||||
|
AuthenticationWebIntegrationTest.java:48 No ModelAndView found
|
||||||
|
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=AuthenticationWebIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
PostgreSQL: 18.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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: 11, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
PostgreSQL: 18.4
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This is a server-side MockMvc test, not a real-browser or accessibility run. It does not validate the future shared-shell styling, login throttling, production transport/cookie configuration, or external identity providers. The milestone does not change activation token creation, persistence, or email delivery.
|
||||||
@@ -31,7 +31,7 @@ class SecurityConfiguration {
|
|||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.formLogin(form -> form.defaultSuccessUrl("/", true))
|
.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true))
|
||||||
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
||||||
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
|
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
class AuthenticationController {
|
||||||
|
@GetMapping("/login")
|
||||||
|
String login() {
|
||||||
|
return "accounts/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head><meta charset="utf-8"><title>Sign in · Lab Timesheet</title></head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p th:if="${param.error}">Invalid email or password</p>
|
||||||
|
<p th:if="${param.logout}">You have signed out</p>
|
||||||
|
<p th:if="${param.activated}">Your account is active. Sign in to continue.</p>
|
||||||
|
<form method="post" th:action="@{/login}">
|
||||||
|
<label for="username">Email</label>
|
||||||
|
<input id="username" name="username" type="email" autocomplete="username" required autofocus>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.controller;
|
||||||
|
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
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 com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
|
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||||
|
import org.hamcrest.Matchers;
|
||||||
|
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.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_CLASS)
|
||||||
|
class AuthenticationWebIntegrationTest {
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BootstrapService bootstrap;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void initializeAdmin() {
|
||||||
|
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void projectLoginPageSupportsFailureNormalizedSuccessAndLogout() throws Exception {
|
||||||
|
mockMvc.perform(get("/login"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(view().name("accounts/login"))
|
||||||
|
.andExpect(content().string(Matchers.containsString("Sign in")))
|
||||||
|
.andExpect(content().string(Matchers.containsString("action=\"/login\"")));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/login")
|
||||||
|
.with(csrf())
|
||||||
|
.param("username", " ADMIN@EXAMPLE.COM ")
|
||||||
|
.param("password", "incorrect password"))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/login?error"))
|
||||||
|
.andExpect(unauthenticated());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/login").param("error", ""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(view().name("accounts/login"))
|
||||||
|
.andExpect(content().string(Matchers.containsString("Invalid email or password")));
|
||||||
|
|
||||||
|
var login = mockMvc.perform(post("/login")
|
||||||
|
.with(csrf())
|
||||||
|
.param("username", " ADMIN@EXAMPLE.COM ")
|
||||||
|
.param("password", "correct horse battery staple"))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/"))
|
||||||
|
.andExpect(authenticated().withUsername("admin@example.com"))
|
||||||
|
.andReturn();
|
||||||
|
var session = (MockHttpSession) login.getRequest().getSession(false);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/logout").session(session).with(csrf()))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/login?logout"))
|
||||||
|
.andExpect(unauthenticated());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/login").param("logout", ""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(view().name("accounts/login"))
|
||||||
|
.andExpect(content().string(Matchers.containsString("You have signed out")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user