Implement auth domain with outside-in TDD (22 tests)
Controller (7 tests): signup, login, logout, GET/PATCH me. Standalone MockMvc setup (Boot 4 removed @WebMvcTest). Service (11 tests): signup with conflict check, login with password/active validation, getCurrentUser with household info, updateProfile with password change flow. Repository (4 tests): save/find, case-insensitive email via IgnoreCase queries (citext + Hibernate needs explicit IgnoreCase), existsByEmail. Also includes: - SecurityConfig: session auth, CSRF, role-based authorization - CustomUserDetailsService: loads UserAccount for Spring Security - UserAccount, Household, HouseholdMember JPA entities - spring-boot-flyway dependency (Boot 4 requires explicit module) - ddl-auto=none (Flyway owns schema, validate fails on citext) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
65
backend/src/main/java/com/recipeapp/auth/AuthController.java
Normal file
65
backend/src/main/java/com/recipeapp/auth/AuthController.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.dto.*;
|
||||
import com.recipeapp.common.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> signup(
|
||||
@Valid @RequestBody SignupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.signup(request);
|
||||
httpRequest.getSession(true);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> login(
|
||||
@Valid @RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UserResponse user = authService.login(request);
|
||||
HttpSession session = httpRequest.getSession(true);
|
||||
session.setAttribute("user_email", user.email());
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
||||
HttpSession session = httpRequest.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> me(Principal principal) {
|
||||
UserResponse user = authService.getCurrentUser(principal.getName());
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
|
||||
@PatchMapping("/me")
|
||||
public ResponseEntity<ApiResponse<UserResponse>> updateProfile(
|
||||
Principal principal,
|
||||
@Valid @RequestBody UpdateProfileRequest request) {
|
||||
UserResponse user = authService.updateProfile(principal.getName(), request);
|
||||
return ResponseEntity.ok(ApiResponse.success(user));
|
||||
}
|
||||
}
|
||||
10
backend/src/main/java/com/recipeapp/auth/AuthService.java
Normal file
10
backend/src/main/java/com/recipeapp/auth/AuthService.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.dto.*;
|
||||
|
||||
public interface AuthService {
|
||||
UserResponse signup(SignupRequest request);
|
||||
UserResponse login(LoginRequest request);
|
||||
UserResponse getCurrentUser(String email);
|
||||
UserResponse updateProfile(String email, UpdateProfileRequest request);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.dto.*;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdMemberRepository;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AuthServiceImpl implements AuthService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final HouseholdMemberRepository householdMemberRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AuthServiceImpl(UserAccountRepository userAccountRepository,
|
||||
HouseholdMemberRepository householdMemberRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.householdMemberRepository = householdMemberRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserResponse signup(SignupRequest request) {
|
||||
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
|
||||
throw new ConflictException("Email already registered");
|
||||
}
|
||||
var user = new UserAccount(
|
||||
request.email(),
|
||||
request.displayName(),
|
||||
passwordEncoder.encode(request.password())
|
||||
);
|
||||
user = userAccountRepository.save(user);
|
||||
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse login(LoginRequest request) {
|
||||
UserAccount user = userAccountRepository.findByEmailIgnoreCase(request.email())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invalid email or password"));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new ValidationException("Account is deactivated");
|
||||
}
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new ValidationException("Invalid email or password");
|
||||
}
|
||||
return toUserResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse getCurrentUser(String email) {
|
||||
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
return toUserResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserResponse updateProfile(String email, UpdateProfileRequest request) {
|
||||
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
if (request.displayName() != null) {
|
||||
user.setDisplayName(request.displayName());
|
||||
}
|
||||
if (request.newPassword() != null) {
|
||||
if (request.currentPassword() == null) {
|
||||
throw new ValidationException("Current password is required to set a new password");
|
||||
}
|
||||
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
|
||||
throw new ValidationException("Current password is incorrect");
|
||||
}
|
||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
}
|
||||
user = userAccountRepository.save(user);
|
||||
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(UserAccount user) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
|
||||
.map(member -> UserResponse.withHousehold(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(),
|
||||
member.getHousehold().getId(), member.getHousehold().getName(),
|
||||
member.getRole(), user.getSystemRole()))
|
||||
.orElse(UserResponse.withHousehold(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(),
|
||||
null, null, null, user.getSystemRole()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public CustomUserDetailsService(UserAccountRepository userAccountRepository) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||
UserAccount account = userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
|
||||
|
||||
if (!account.isActive()) {
|
||||
throw new UsernameNotFoundException("Account is deactivated: " + email);
|
||||
}
|
||||
|
||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + account.getSystemRole().toUpperCase()));
|
||||
|
||||
return new User(account.getEmail(), account.getPasswordHash(), authorities);
|
||||
}
|
||||
}
|
||||
43
backend/src/main/java/com/recipeapp/auth/SecurityConfig.java
Normal file
43
backend/src/main/java/com/recipeapp/auth/SecurityConfig.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.sessionManagement(session -> session
|
||||
.maximumSessions(1));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserAccountRepository extends JpaRepository<UserAccount, UUID> {
|
||||
Optional<UserAccount> findByEmailIgnoreCase(String email);
|
||||
boolean existsByEmailIgnoreCase(String email);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record SignupRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 8) String password,
|
||||
@NotBlank @Size(max = 100) String displayName
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
@Size(max = 100) String displayName,
|
||||
String currentPassword,
|
||||
@Size(min = 8) String newPassword
|
||||
) {}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.recipeapp.auth.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record UserResponse(
|
||||
UUID id,
|
||||
String email,
|
||||
String displayName,
|
||||
UUID householdId,
|
||||
String householdName,
|
||||
String householdRole,
|
||||
String systemRole
|
||||
) {
|
||||
public static UserResponse basic(UUID id, String email, String displayName) {
|
||||
return new UserResponse(id, email, displayName, null, null, null, null);
|
||||
}
|
||||
|
||||
public static UserResponse withHousehold(UUID id, String email, String displayName,
|
||||
UUID householdId, String householdName,
|
||||
String householdRole, String systemRole) {
|
||||
return new UserResponse(id, email, displayName, householdId, householdName,
|
||||
householdRole, systemRole);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.recipeapp.auth.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_account")
|
||||
public class UserAccount {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true, columnDefinition = "citext")
|
||||
private String email;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 100)
|
||||
private String displayName;
|
||||
|
||||
@Column(name = "password_hash", nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(name = "system_role", nullable = false, length = 10)
|
||||
private String systemRole = "user";
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean isActive = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
protected UserAccount() {}
|
||||
|
||||
public UserAccount(String email, String displayName, String passwordHash) {
|
||||
this.email = email;
|
||||
this.displayName = displayName;
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getPasswordHash() { return passwordHash; }
|
||||
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
|
||||
public String getSystemRole() { return systemRole; }
|
||||
public void setSystemRole(String systemRole) { this.systemRole = systemRole; }
|
||||
public boolean isActive() { return isActive; }
|
||||
public void setActive(boolean active) { isActive = active; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.household;
|
||||
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.recipeapp.household.entity;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "household")
|
||||
public class Household {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by", nullable = false)
|
||||
private UserAccount createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected Household() {}
|
||||
|
||||
public Household(String name, UserAccount createdBy) {
|
||||
this.name = name;
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public UserAccount getCreatedBy() { return createdBy; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.recipeapp.household.entity;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "household_member")
|
||||
public class HouseholdMember {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "household_id", nullable = false)
|
||||
private Household household;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false, unique = true)
|
||||
private UserAccount user;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String role;
|
||||
|
||||
@Column(name = "joined_at", nullable = false, updatable = false)
|
||||
private Instant joinedAt;
|
||||
|
||||
protected HouseholdMember() {}
|
||||
|
||||
public HouseholdMember(Household household, UserAccount user, String role) {
|
||||
this.household = household;
|
||||
this.user = user;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
joinedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public UserAccount getUser() { return user; }
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
public Instant getJoinedAt() { return joinedAt; }
|
||||
}
|
||||
@@ -10,7 +10,7 @@ spring:
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
ddl-auto: none
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
@@ -5,20 +5,20 @@ import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@ActiveProfiles("test")
|
||||
public abstract class AbstractIntegrationTest {
|
||||
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
.withDatabaseName("mealprep")
|
||||
.withUsername("mealprep")
|
||||
.withPassword("mealprep");
|
||||
|
||||
static {
|
||||
postgres.start();
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
|
||||
136
backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java
Normal file
136
backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java
Normal file
@@ -0,0 +1,136 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.recipeapp.auth.dto.*;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock
|
||||
private AuthService authService;
|
||||
|
||||
@InjectMocks
|
||||
private AuthController authController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(authController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void signupShouldReturn201WithUserResponse() throws Exception {
|
||||
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
||||
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
|
||||
|
||||
when(authService.signup(any(SignupRequest.class))).thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/v1/auth/signup")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.email").value("sarah@example.com"))
|
||||
.andExpect(jsonPath("$.data.displayName").value("Sarah"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void signupShouldReturn400WhenEmailInvalid() throws Exception {
|
||||
var request = new SignupRequest("not-an-email", "s3cure!Pass", "Sarah");
|
||||
|
||||
mockMvc.perform(post("/v1/auth/signup")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signupShouldReturn400WhenPasswordTooShort() throws Exception {
|
||||
var request = new SignupRequest("sarah@example.com", "short", "Sarah");
|
||||
|
||||
mockMvc.perform(post("/v1/auth/signup")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginShouldReturn200WithUserResponse() throws Exception {
|
||||
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
||||
var response = UserResponse.withHousehold(
|
||||
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
||||
UUID.randomUUID(), "Smith family", "planner", "user");
|
||||
|
||||
when(authService.login(any(LoginRequest.class))).thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.email").value("sarah@example.com"))
|
||||
.andExpect(jsonPath("$.data.householdRole").value("planner"))
|
||||
.andExpect(jsonPath("$.data.systemRole").value("user"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutShouldReturn204() throws Exception {
|
||||
mockMvc.perform(post("/v1/auth/logout"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMeShouldReturn200WithUserResponse() throws Exception {
|
||||
var response = UserResponse.withHousehold(
|
||||
UUID.randomUUID(), "sarah@example.com", "Sarah",
|
||||
UUID.randomUUID(), "Smith family", "planner", "user");
|
||||
|
||||
when(authService.getCurrentUser("sarah@example.com")).thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/v1/auth/me")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.email").value("sarah@example.com"))
|
||||
.andExpect(jsonPath("$.data.householdName").value("Smith family"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileShouldReturn200() throws Exception {
|
||||
var request = new UpdateProfileRequest("Sarah S.", null, null);
|
||||
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah S.");
|
||||
|
||||
when(authService.updateProfile(eq("sarah@example.com"), any(UpdateProfileRequest.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
mockMvc.perform(patch("/v1/auth/me")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.data.displayName").value("Sarah S."));
|
||||
}
|
||||
}
|
||||
183
backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java
Normal file
183
backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java
Normal file
@@ -0,0 +1,183 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.auth.dto.*;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdMemberRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Mock
|
||||
private HouseholdMemberRepository householdMemberRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@InjectMocks
|
||||
private AuthServiceImpl authService;
|
||||
|
||||
@Test
|
||||
void signupShouldCreateUserAndReturnResponse() {
|
||||
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
||||
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("sarah@example.com")).thenReturn(false);
|
||||
when(passwordEncoder.encode("s3cure!Pass")).thenReturn("hashed");
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> {
|
||||
UserAccount saved = invocation.getArgument(0);
|
||||
return saved;
|
||||
});
|
||||
|
||||
UserResponse result = authService.signup(request);
|
||||
|
||||
assertThat(result.email()).isEqualTo("sarah@example.com");
|
||||
assertThat(result.displayName()).isEqualTo("Sarah");
|
||||
verify(userAccountRepository).save(any(UserAccount.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void signupShouldThrowConflictWhenEmailExists() {
|
||||
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
|
||||
|
||||
when(userAccountRepository.existsByEmailIgnoreCase("sarah@example.com")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> authService.signup(request))
|
||||
.isInstanceOf(ConflictException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginShouldReturnUserWhenCredentialsValid() {
|
||||
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("s3cure!Pass", "hashed")).thenReturn(true);
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
UserResponse result = authService.login(request);
|
||||
|
||||
assertThat(result.email()).isEqualTo("sarah@example.com");
|
||||
assertThat(result.displayName()).isEqualTo("Sarah");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginShouldThrowWhenEmailNotFound() {
|
||||
var request = new LoginRequest("unknown@example.com", "password");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> authService.login(request))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginShouldThrowWhenPasswordInvalid() {
|
||||
var request = new LoginRequest("sarah@example.com", "wrongpass");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("wrongpass", "hashed")).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> authService.login(request))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginShouldThrowWhenAccountInactive() {
|
||||
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
user.setActive(false);
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
|
||||
assertThatThrownBy(() -> authService.login(request))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserShouldReturnUserWithHouseholdInfo() {
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
var household = new Household("Smith family", user);
|
||||
var member = new HouseholdMember(household, user, "planner");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
|
||||
|
||||
UserResponse result = authService.getCurrentUser("sarah@example.com");
|
||||
|
||||
assertThat(result.email()).isEqualTo("sarah@example.com");
|
||||
assertThat(result.householdName()).isEqualTo("Smith family");
|
||||
assertThat(result.householdRole()).isEqualTo("planner");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileShouldUpdateDisplayName() {
|
||||
var request = new UpdateProfileRequest("Sarah S.", null, null);
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
UserResponse result = authService.updateProfile("sarah@example.com", request);
|
||||
|
||||
assertThat(result.displayName()).isEqualTo("Sarah S.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileShouldChangePasswordWhenCurrentPasswordValid() {
|
||||
var request = new UpdateProfileRequest(null, "oldpass", "newpassword");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed_old");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("oldpass", "hashed_old")).thenReturn(true);
|
||||
when(passwordEncoder.encode("newpassword")).thenReturn("hashed_new");
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
authService.updateProfile("sarah@example.com", request);
|
||||
|
||||
verify(passwordEncoder).encode("newpassword");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileShouldThrowWhenCurrentPasswordWrong() {
|
||||
var request = new UpdateProfileRequest(null, "wrongpass", "newpassword");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed_old");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("wrongpass", "hashed_old")).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> authService.updateProfile("sarah@example.com", request))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileShouldThrowWhenNewPasswordWithoutCurrentPassword() {
|
||||
var request = new UpdateProfileRequest(null, null, "newpassword");
|
||||
var user = new UserAccount("sarah@example.com", "Sarah", "hashed");
|
||||
|
||||
when(userAccountRepository.findByEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(user));
|
||||
|
||||
assertThatThrownBy(() -> authService.updateProfile("sarah@example.com", request))
|
||||
.isInstanceOf(ValidationException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.recipeapp.auth;
|
||||
|
||||
import com.recipeapp.AbstractIntegrationTest;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class UserAccountRepositoryTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Test
|
||||
void shouldSaveAndFindByEmail() {
|
||||
var user = new UserAccount("test@example.com", "Test User", "hashed_pw");
|
||||
userAccountRepository.save(user);
|
||||
|
||||
var found = userAccountRepository.findByEmailIgnoreCase("test@example.com");
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getDisplayName()).isEqualTo("Test User");
|
||||
assertThat(found.get().getSystemRole()).isEqualTo("user");
|
||||
assertThat(found.get().isActive()).isTrue();
|
||||
assertThat(found.get().getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyWhenEmailNotFound() {
|
||||
var found = userAccountRepository.findByEmailIgnoreCase("nonexistent@example.com");
|
||||
assertThat(found).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsByEmailIgnoreCaseShouldReturnTrueWhenExists() {
|
||||
var user = new UserAccount("exists@example.com", "Exists", "hashed");
|
||||
userAccountRepository.save(user);
|
||||
|
||||
assertThat(userAccountRepository.existsByEmailIgnoreCase("exists@example.com")).isTrue();
|
||||
assertThat(userAccountRepository.existsByEmailIgnoreCase("nope@example.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCaseInsensitiveEmail() {
|
||||
var user = new UserAccount("Sarah@Example.COM", "Sarah", "hashed");
|
||||
userAccountRepository.save(user);
|
||||
|
||||
var found = userAccountRepository.findByEmailIgnoreCase("sarah@example.com");
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getDisplayName()).isEqualTo("Sarah");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:tc:postgresql:16-alpine:///mealprep
|
||||
username: mealprep
|
||||
password: mealprep
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
ddl-auto: none
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
Reference in New Issue
Block a user