diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index a474fa8b..d974deb3 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -16,4 +16,16 @@ public interface PetRepository extends JpaRepository { "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))") Page searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable); + + @Query("SELECT p FROM Pet p WHERE LOWER(p.petStatus) = 'available' AND " + + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species))") + Page searchPublicPets(@Param("q") String query, @Param("species") String species, Pageable pageable); + + @Query("SELECT DISTINCT p FROM Pet p LEFT JOIN Adoption a ON a.pet = p AND LOWER(a.adoptionStatus) = 'completed' WHERE " + + "(LOWER(p.petStatus) = 'available' OR a.customer.userId = :userId) AND " + + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))") + Page searchCustomerVisiblePets(@Param("userId") Long userId, @Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable); } diff --git a/backend/src/main/java/com/petshop/backend/service/PetService.java b/backend/src/main/java/com/petshop/backend/service/PetService.java index 4672ee85..dc5fa61e 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -7,12 +7,16 @@ import com.petshop.backend.entity.Adoption; import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.security.AppPrincipal; import com.petshop.backend.repository.AdoptionRepository; import com.petshop.backend.repository.PetRepository; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -34,13 +38,38 @@ public class PetService { } public Page getAllPets(String query, String species, String status, Pageable pageable) { - return petRepository.searchPets(normalizeFilter(query), normalizeFilter(species), normalizeFilter(status), pageable) + String normalizedQuery = normalizeFilter(query); + String normalizedSpecies = normalizeFilter(species); + String normalizedStatus = normalizeFilter(status); + CurrentViewer viewer = getCurrentViewer(); + + Page pets; + if (viewer == null) { + if (!isAllowedPublicStatus(normalizedStatus)) { + return new PageImpl<>(java.util.List.of(), pageable, 0); + } + pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable); + } else if (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN) { + pets = petRepository.searchPets(normalizedQuery, normalizedSpecies, normalizedStatus, pageable); + } else if (viewer.role() == User.Role.CUSTOMER) { + if (!isAllowedCustomerStatus(normalizedStatus)) { + return new PageImpl<>(java.util.List.of(), pageable, 0); + } + pets = petRepository.searchCustomerVisiblePets(viewer.userId(), normalizedQuery, normalizedSpecies, normalizedStatus, pageable); + } else { + pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable); + } + + return pets .map(this::mapToResponse); } public PetResponse getPetById(Long id) { Pet pet = petRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + if (!canViewPet(pet, getCurrentViewer())) { + throw new ResourceNotFoundException("Pet not found with id: " + id); + } return mapToResponse(pet); } @@ -110,7 +139,7 @@ public class PetService { if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) { throw new ResourceNotFoundException("Pet image not found for id: " + id); } - if (!canViewPetImage(pet, requesterUserId, requesterRole)) { + if (!canViewPet(pet, new CurrentViewer(requesterUserId, requesterRole))) { throw new ForbiddenImageAccessException(); } Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl()); @@ -122,14 +151,21 @@ public class PetService { return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus())); } - private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) { + private boolean canViewPet(Pet pet, CurrentViewer viewer) { if (isPubliclyVisible(pet)) { return true; } - if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) { + if (viewer != null && (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN)) { return true; } - if (requesterUserId == null) { + if (viewer == null || viewer.userId() == null) { + return false; + } + return isAdoptedByUser(pet, viewer.userId()); + } + + private boolean isAdoptedByUser(Pet pet, Long userId) { + if (userId == null) { return false; } if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) { @@ -137,10 +173,22 @@ public class PetService { } return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed") .map(Adoption::getCustomer) - .map(customer -> requesterUserId.equals(customer.getUserId())) + .map(customer -> userId.equals(customer.getUserId())) .orElse(false); } + private CurrentViewer getCurrentViewer() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof AppPrincipal appPrincipal) { + return new CurrentViewer(appPrincipal.getUserId(), appPrincipal.getRole()); + } + return null; + } + private Pet findPet(Long id) { return petRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); @@ -177,6 +225,14 @@ public class PetService { return status == null ? "" : status.trim(); } + private boolean isAllowedPublicStatus(String status) { + return status == null || "available".equalsIgnoreCase(status); + } + + private boolean isAllowedCustomerStatus(String status) { + return status == null || "available".equalsIgnoreCase(status) || "adopted".equalsIgnoreCase(status); + } + private String normalizeFilter(String value) { if (value == null) { return null; @@ -203,6 +259,9 @@ public class PetService { public record ImagePayload(Resource resource, MediaType mediaType) { } + private record CurrentViewer(Long userId, User.Role role) { + } + public static class ForbiddenImageAccessException extends RuntimeException { } } diff --git a/backend/src/test/java/com/petshop/backend/service/PetServiceTest.java b/backend/src/test/java/com/petshop/backend/service/PetServiceTest.java new file mode 100644 index 00000000..9107ebd9 --- /dev/null +++ b/backend/src/test/java/com/petshop/backend/service/PetServiceTest.java @@ -0,0 +1,165 @@ +package com.petshop.backend.service; + +import com.petshop.backend.entity.Adoption; +import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.Pet; +import com.petshop.backend.entity.User; +import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.AdoptionRepository; +import com.petshop.backend.repository.PetRepository; +import com.petshop.backend.security.AppPrincipal; +import org.junit.jupiter.api.AfterEach; +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.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PetServiceTest { + + @Mock + private PetRepository petRepository; + + @Mock + private AdoptionRepository adoptionRepository; + + @Mock + private CatalogImageStorageService catalogImageStorageService; + + @InjectMocks + private PetService petService; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void getAllPetsAnonymousReturnsOnlyPublicPets() { + Pageable pageable = PageRequest.of(0, 10); + Pet availablePet = pet(1L, "Buddy", "Available"); + when(petRepository.searchPublicPets(null, null, pageable)).thenReturn(new PageImpl<>(List.of(availablePet), pageable, 1)); + + var result = petService.getAllPets(null, null, null, pageable); + + assertEquals(1, result.getTotalElements()); + assertEquals("Buddy", result.getContent().get(0).getPetName()); + verify(petRepository).searchPublicPets(null, null, pageable); + verify(petRepository, never()).searchPets(null, null, null, pageable); + } + + @Test + void getAllPetsAnonymousWithAdoptedStatusReturnsEmptyPage() { + Pageable pageable = PageRequest.of(0, 10); + + var result = petService.getAllPets(null, null, "Adopted", pageable); + + assertEquals(0, result.getTotalElements()); + verify(petRepository, never()).searchPublicPets(null, null, pageable); + } + + @Test + void getAllPetsCustomerReturnsVisiblePetsOnly() { + Pageable pageable = PageRequest.of(0, 10); + setAuthentication(25L, User.Role.CUSTOMER); + Pet availablePet = pet(1L, "Buddy", "Available"); + Pet adoptedPet = pet(2L, "Luna", "Adopted"); + when(petRepository.searchCustomerVisiblePets(25L, null, null, null, pageable)) + .thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2)); + + var result = petService.getAllPets(null, null, null, pageable); + + assertEquals(2, result.getTotalElements()); + verify(petRepository).searchCustomerVisiblePets(25L, null, null, null, pageable); + } + + @Test + void getAllPetsAdminReturnsAllPets() { + Pageable pageable = PageRequest.of(0, 10); + setAuthentication(99L, User.Role.ADMIN); + Pet availablePet = pet(1L, "Buddy", "Available"); + Pet adoptedPet = pet(2L, "Luna", "Adopted"); + when(petRepository.searchPets(null, null, null, pageable)) + .thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2)); + + var result = petService.getAllPets(null, null, null, pageable); + + assertEquals(2, result.getTotalElements()); + verify(petRepository).searchPets(null, null, null, pageable); + } + + @Test + void getPetByIdHidesAdoptedPetFromUnrelatedCustomer() { + setAuthentication(50L, User.Role.CUSTOMER); + Pet adoptedPet = pet(2L, "Luna", "Adopted"); + when(petRepository.findById(2L)).thenReturn(Optional.of(adoptedPet)); + when(adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(2L, "Completed")) + .thenReturn(Optional.of(adoption(2L, 25L))); + + assertThrows(ResourceNotFoundException.class, () -> petService.getPetById(2L)); + } + + @Test + void getPetByIdAllowsOwnerToSeeAdoptedPet() { + setAuthentication(25L, User.Role.CUSTOMER); + Pet adoptedPet = pet(2L, "Luna", "Adopted"); + when(petRepository.findById(2L)).thenReturn(Optional.of(adoptedPet)); + when(adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(2L, "Completed")) + .thenReturn(Optional.of(adoption(2L, 25L))); + + var result = petService.getPetById(2L); + + assertEquals(2L, result.getPetId()); + } + + private void setAuthentication(Long userId, User.Role role) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + new AppPrincipal(userId, "user", role, 0), + "n/a", + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) + ) + ); + } + + private Pet pet(Long id, String name, String status) { + Pet pet = new Pet(); + pet.setPetId(id); + pet.setPetName(name); + pet.setPetSpecies("Cat"); + pet.setPetBreed("Mixed"); + pet.setPetAge(2); + pet.setPetStatus(status); + pet.setPetPrice(java.math.BigDecimal.TEN); + return pet; + } + + private Adoption adoption(Long petId, Long userId) { + Adoption adoption = new Adoption(); + Pet pet = new Pet(); + pet.setPetId(petId); + adoption.setPet(pet); + Customer customer = new Customer(); + customer.setCustomerId(1L); + customer.setUserId(userId); + adoption.setCustomer(customer); + adoption.setAdoptionStatus("Completed"); + return adoption; + } +}