Operators can fix prod admin passwords without email via Byt lösenord; end users can use forgot-password when SMTP is configured. Local and CI use Mailpit to capture outbound mail and verify reset links end-to-end. - Backend: V8 password_reset_tokens, PasswordResetService, EmailService, POST /api/auth/forgot-password, reset-password, change-password - Optional testToken in forgot-password response (docker profile only, for E2E) - Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage, routes, login link, header Byt lösenord - Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack - E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery - Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend - Docs: README email section, production-email-checklist, .env.example - Unit/integration tests for reset, change password, and Vitest page specs Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
10 KiB
Java
226 lines
10 KiB
Java
package se.bilhalsning.controller;
|
|
|
|
import static org.mockito.Mockito.verify;
|
|
import static org.mockito.Mockito.when;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
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.http.MediaType;
|
|
import org.springframework.security.test.context.support.WithMockUser;
|
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
import se.bilhalsning.dto.LoginRequest;
|
|
import se.bilhalsning.dto.RegisterRequest;
|
|
import se.bilhalsning.entity.User;
|
|
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
|
import se.bilhalsning.security.JwtService;
|
|
import java.util.Optional;
|
|
import se.bilhalsning.service.PasswordResetService;
|
|
import se.bilhalsning.service.UserService;
|
|
|
|
@SpringBootTest
|
|
@AutoConfigureMockMvc
|
|
class AuthControllerTest {
|
|
|
|
@Autowired
|
|
private MockMvc mockMvc;
|
|
|
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
|
@MockitoBean
|
|
private UserService userService;
|
|
|
|
@MockitoBean
|
|
private PasswordResetService passwordResetService;
|
|
|
|
@MockitoBean
|
|
private JwtService jwtService;
|
|
|
|
@Test
|
|
void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception {
|
|
when(userService.createUser("new@example.com", "password123")).thenReturn(null);
|
|
when(jwtService.generateToken("new@example.com", "user")).thenReturn("test-jwt-token");
|
|
|
|
RegisterRequest request = new RegisterRequest("new@example.com", "password123");
|
|
mockMvc.perform(post("/api/auth/register")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isCreated())
|
|
.andExpect(jsonPath("$.token").value("test-jwt-token"));
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn409WhenEmailAlreadyExists() throws Exception {
|
|
when(userService.createUser("taken@example.com", "password123"))
|
|
.thenThrow(new EmailAlreadyExistsException("taken@example.com"));
|
|
|
|
RegisterRequest request = new RegisterRequest("taken@example.com", "password123");
|
|
mockMvc.perform(post("/api/auth/register")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isConflict())
|
|
.andExpect(jsonPath("$.message").value("E-postadressen är redan registrerad"));
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn400WhenEmailIsInvalid() throws Exception {
|
|
RegisterRequest request = new RegisterRequest("not-an-email", "password123");
|
|
mockMvc.perform(post("/api/auth/register")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn400WhenPasswordIsTooShort() throws Exception {
|
|
RegisterRequest request = new RegisterRequest("new@example.com", "1234567");
|
|
mockMvc.perform(post("/api/auth/register")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn400WhenEmailIsMissing() throws Exception {
|
|
RegisterRequest request = new RegisterRequest("", "password123");
|
|
mockMvc.perform(post("/api/auth/register")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
|
|
User user = new User();
|
|
user.setEmail("user@example.com");
|
|
user.setRole("user");
|
|
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
|
|
when(jwtService.generateToken("user@example.com", "user")).thenReturn("login-jwt-token");
|
|
|
|
LoginRequest request = new LoginRequest("user@example.com", "password123");
|
|
mockMvc.perform(post("/api/auth/login")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.token").value("login-jwt-token"));
|
|
}
|
|
|
|
@Test
|
|
void shouldReturnAdminRoleInTokenWhenAdminLogsIn() throws Exception {
|
|
User admin = new User();
|
|
admin.setEmail("admin@bilhalsning.se");
|
|
admin.setRole("admin");
|
|
when(userService.authenticate("admin@bilhalsning.se", "admin1234")).thenReturn(admin);
|
|
when(jwtService.generateToken("admin@bilhalsning.se", "admin")).thenReturn("admin-jwt-token");
|
|
|
|
LoginRequest request = new LoginRequest("admin@bilhalsning.se", "admin1234");
|
|
mockMvc.perform(post("/api/auth/login")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.token").value("admin-jwt-token"));
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
|
|
when(userService.authenticate("user@example.com", "wrongpassword"))
|
|
.thenThrow(new InvalidCredentialsException());
|
|
|
|
LoginRequest request = new LoginRequest("user@example.com", "wrongpassword");
|
|
mockMvc.perform(post("/api/auth/login")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isUnauthorized())
|
|
.andExpect(jsonPath("$.message").value("Felaktig e-post eller lösenord"));
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn400WhenLoginEmailIsBlank() throws Exception {
|
|
LoginRequest request = new LoginRequest("", "password123");
|
|
mockMvc.perform(post("/api/auth/login")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn400WhenLoginPasswordIsBlank() throws Exception {
|
|
LoginRequest request = new LoginRequest("user@example.com", "");
|
|
mockMvc.perform(post("/api/auth/login")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn400WhenLoginEmailIsInvalid() throws Exception {
|
|
LoginRequest request = new LoginRequest("not-an-email", "password123");
|
|
mockMvc.perform(post("/api/auth/login")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(request)))
|
|
.andExpect(status().isBadRequest());
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn200WhenForgotPasswordRequested() throws Exception {
|
|
when(passwordResetService.requestReset("user@example.com")).thenReturn(Optional.empty());
|
|
|
|
mockMvc.perform(post("/api/auth/forgot-password")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"email\":\"user@example.com\"}"))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.message")
|
|
.value("Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."))
|
|
.andExpect(jsonPath("$.testToken").doesNotExist());
|
|
|
|
verify(passwordResetService).requestReset("user@example.com");
|
|
}
|
|
|
|
@Test
|
|
void shouldIncludeTestTokenWhenServiceReturnsToken() throws Exception {
|
|
when(passwordResetService.requestReset("user@example.com"))
|
|
.thenReturn(Optional.of("e2e-reset-token"));
|
|
|
|
mockMvc.perform(post("/api/auth/forgot-password")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"email\":\"user@example.com\"}"))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.testToken").value("e2e-reset-token"));
|
|
}
|
|
|
|
@Test
|
|
void shouldReturn200WhenResetPasswordSucceeds() throws Exception {
|
|
mockMvc.perform(post("/api/auth/reset-password")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"token\":\"abc\",\"password\":\"newpassword123\"}"))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats. Du kan nu logga in."));
|
|
}
|
|
|
|
@Test
|
|
@WithMockUser(username = "admin@bilhej.se")
|
|
void shouldReturn200WhenChangePasswordSucceeds() throws Exception {
|
|
mockMvc.perform(post("/api/auth/change-password")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(
|
|
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats."));
|
|
}
|
|
|
|
@Test
|
|
void shouldRejectChangePasswordWithoutAuth() throws Exception {
|
|
mockMvc.perform(post("/api/auth/change-password")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(
|
|
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
|
.andExpect(status().isForbidden());
|
|
}
|
|
}
|