Harden assignment rules

This commit is contained in:
2026-04-05 15:51:11 -06:00
parent 0294f078f9
commit 30b5041ae5
15 changed files with 546 additions and 120 deletions

View File

@@ -0,0 +1,34 @@
package com.petshop.backend.config;
import com.petshop.backend.repository.CustomerPetRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
@Component
@Profile("local")
public class LocalAppointmentCustomerSeedInitializer implements CommandLineRunner {
private final DataSource dataSource;
private final CustomerPetRepository customerPetRepository;
public LocalAppointmentCustomerSeedInitializer(DataSource dataSource, CustomerPetRepository customerPetRepository) {
this.dataSource = dataSource;
this.customerPetRepository = customerPetRepository;
}
@Override
public void run(String... args) {
if (customerPetRepository.count() > 0) {
return;
}
ResourceDatabasePopulator populator = new ResourceDatabasePopulator(false, false, "UTF-8",
new ClassPathResource("dev/seed_demo_customer_pets.sql"));
populator.execute(dataSource);
}
}

View File

@@ -71,21 +71,8 @@ public class AdoptionController {
}
@PostMapping
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<AdoptionResponse> createAdoption(@Valid @RequestBody AdoptionRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
if (role != null && role.equals("CUSTOMER")) {
Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository);
if (!request.getCustomerId().equals(customer.getCustomerId())) {
throw new org.springframework.security.access.AccessDeniedException("You can only create adoptions for yourself");
}
}
return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request));
}

View File

@@ -57,6 +57,16 @@ public class DropdownController {
);
}
@GetMapping("/adoption-pets")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<List<DropdownOption>> getAdoptionPets() {
return ResponseEntity.ok(
petRepository.findAllByPetStatusIgnoreCaseOrderByPetNameAsc("Available").stream()
.map(p -> new DropdownOption(p.getPetId(), p.getPetName()))
.collect(Collectors.toList())
);
}
@GetMapping("/customers")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<List<DropdownOption>> getCustomers() {
@@ -67,6 +77,16 @@ public class DropdownController {
);
}
@GetMapping("/appointment-customers")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<List<DropdownOption>> getAppointmentCustomers() {
return ResponseEntity.ok(
customerRepository.findAllWithPets().stream()
.map(c -> new DropdownOption(c.getCustomerId(), c.getFirstName() + " " + c.getLastName()))
.collect(Collectors.toList())
);
}
@GetMapping("/customers/{customerId}/pets")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<List<DropdownOption>> getCustomerPets(@PathVariable Long customerId) {
@@ -174,8 +194,8 @@ public class DropdownController {
return false;
}
return userRepository.findById(userId)
.map(User::getRole)
.filter(role -> role == User.Role.STAFF)
.filter(user -> user.getRole() == User.Role.STAFF)
.filter(user -> Boolean.TRUE.equals(user.getActive()))
.isPresent();
}
}

View File

@@ -28,4 +28,8 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
Page<Adoption> searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable);
Optional<Adoption> findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus);
boolean existsByPetPetIdAndAdoptionStatusIgnoreCaseAndAdoptionIdNot(Long petId, String adoptionStatus, Long adoptionId);
boolean existsByPetPetIdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus);
}

View File

@@ -16,6 +16,9 @@ public interface CustomerRepository extends JpaRepository<Customer, Long> {
Optional<Customer> findByUserId(Long userId);
List<Customer> findAllByEmail(String email);
@Query("SELECT DISTINCT c FROM Customer c WHERE EXISTS (SELECT cp FROM CustomerPet cp WHERE cp.customer = c) ORDER BY c.firstName ASC, c.lastName ASC")
List<Customer> findAllWithPets();
@Query("SELECT c FROM Customer c WHERE " +
"LOWER(c.firstName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +

View File

@@ -8,9 +8,13 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PetRepository extends JpaRepository<Pet, Long> {
List<Pet> findAllByPetStatusIgnoreCaseOrderByPetNameAsc(String petStatus);
@Query("SELECT p FROM Pet p WHERE " +
"(: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 " +

View File

@@ -22,6 +22,12 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class AdoptionService {
private static final String ADOPTION_STATUS_PENDING = "Pending";
private static final String ADOPTION_STATUS_COMPLETED = "Completed";
private static final String ADOPTION_STATUS_CANCELLED = "Cancelled";
private static final String PET_STATUS_AVAILABLE = "Available";
private static final String PET_STATUS_ADOPTED = "Adopted";
private final AdoptionRepository adoptionRepository;
private final PetRepository petRepository;
private final CustomerRepository customerRepository;
@@ -75,15 +81,18 @@ public class AdoptionService {
Customer customer = customerRepository.findById(request.getCustomerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + request.getCustomerId()));
Employee employee = resolveAdoptionEmployee(request.getEmployeeId());
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
validatePetAvailability(pet, null);
Adoption adoption = new Adoption();
adoption.setPet(pet);
adoption.setCustomer(customer);
adoption.setEmployee(employee);
adoption.setAdoptionDate(request.getAdoptionDate());
adoption.setAdoptionStatus(request.getAdoptionStatus());
adoption.setAdoptionStatus(adoptionStatus);
adoption = adoptionRepository.save(adoption);
syncPetStatus(pet, adoptionStatus, adoption.getAdoptionId());
return mapToResponse(adoption);
}
@@ -98,14 +107,17 @@ public class AdoptionService {
Customer customer = customerRepository.findById(request.getCustomerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + request.getCustomerId()));
Employee employee = resolveAdoptionEmployee(request.getEmployeeId());
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
validatePetAvailability(pet, adoption.getAdoptionId());
adoption.setPet(pet);
adoption.setCustomer(customer);
adoption.setEmployee(employee);
adoption.setAdoptionDate(request.getAdoptionDate());
adoption.setAdoptionStatus(request.getAdoptionStatus());
adoption.setAdoptionStatus(adoptionStatus);
adoption = adoptionRepository.save(adoption);
syncPetStatus(pet, adoptionStatus, adoption.getAdoptionId());
return mapToResponse(adoption);
}
@@ -161,8 +173,49 @@ public class AdoptionService {
return false;
}
return userRepository.findById(userId)
.map(User::getRole)
.filter(role -> role == User.Role.STAFF)
.filter(user -> user.getRole() == User.Role.STAFF)
.filter(user -> Boolean.TRUE.equals(user.getActive()))
.isPresent();
}
private String normalizeAdoptionStatus(String adoptionStatus) {
if (adoptionStatus == null) {
throw new IllegalArgumentException("Adoption status is required");
}
String trimmedStatus = adoptionStatus.trim();
if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(trimmedStatus)) {
return ADOPTION_STATUS_PENDING;
}
if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(trimmedStatus)) {
return ADOPTION_STATUS_COMPLETED;
}
if (ADOPTION_STATUS_CANCELLED.equalsIgnoreCase(trimmedStatus)) {
return ADOPTION_STATUS_CANCELLED;
}
throw new IllegalArgumentException("Adoption status must be Pending, Completed, or Cancelled");
}
private void validatePetAvailability(Pet pet, Long adoptionId) {
boolean adoptedElsewhere = adoptionId == null
? adoptionRepository.existsByPetPetIdAndAdoptionStatusIgnoreCase(pet.getPetId(), ADOPTION_STATUS_COMPLETED)
: adoptionRepository.existsByPetPetIdAndAdoptionStatusIgnoreCaseAndAdoptionIdNot(pet.getPetId(), ADOPTION_STATUS_COMPLETED, adoptionId);
if (adoptedElsewhere) {
throw new IllegalArgumentException("Selected pet has already been adopted");
}
if (!PET_STATUS_AVAILABLE.equalsIgnoreCase(pet.getPetStatus()) && adoptionId == null) {
throw new IllegalArgumentException("Selected pet is not available for adoption");
}
}
private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId) {
boolean completedElsewhere = adoptionId != null
&& adoptionRepository.existsByPetPetIdAndAdoptionStatusIgnoreCaseAndAdoptionIdNot(pet.getPetId(), ADOPTION_STATUS_COMPLETED, adoptionId);
if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus) || completedElsewhere) {
pet.setPetStatus(PET_STATUS_ADOPTED);
} else {
pet.setPetStatus(PET_STATUS_AVAILABLE);
}
petRepository.save(pet);
}
}

View File

@@ -333,8 +333,8 @@ public class AppointmentService {
return false;
}
return userRepository.findById(userId)
.map(User::getRole)
.filter(role -> role == User.Role.STAFF)
.filter(user -> user.getRole() == User.Role.STAFF)
.filter(user -> Boolean.TRUE.equals(user.getActive()))
.isPresent();
}

View File

@@ -0,0 +1,53 @@
INSERT INTO customer_pet (customer_id, pet_name, species, breed, image_url)
SELECT c.customerId, 'Rocky', 'dog', 'Labrador', NULL
FROM customer c
WHERE c.email = 'alex@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM customer_pet cp
WHERE cp.customer_id = c.customerId AND cp.pet_name = 'Rocky'
);
INSERT INTO customer_pet (customer_id, pet_name, species, breed, image_url)
SELECT c.customerId, 'Whiskers', 'cat', 'Persian', NULL
FROM customer c
WHERE c.email = 'emily@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM customer_pet cp
WHERE cp.customer_id = c.customerId AND cp.pet_name = 'Whiskers'
);
INSERT INTO customer_pet (customer_id, pet_name, species, breed, image_url)
SELECT c.customerId, 'Daisy', 'dog', 'Beagle', NULL
FROM customer c
WHERE c.email = 'james@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM customer_pet cp
WHERE cp.customer_id = c.customerId AND cp.pet_name = 'Daisy'
);
INSERT INTO customer_pet (customer_id, pet_name, species, breed, image_url)
SELECT c.customerId, 'Pepper', 'cat', 'Domestic Shorthair', NULL
FROM customer c
WHERE c.email = 'olivia@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM customer_pet cp
WHERE cp.customer_id = c.customerId AND cp.pet_name = 'Pepper'
);
INSERT INTO customer_pet (customer_id, pet_name, species, breed, image_url)
SELECT c.customerId, 'Cooper', 'dog', 'Golden Retriever', NULL
FROM customer c
WHERE c.email = 'william@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM customer_pet cp
WHERE cp.customer_id = c.customerId AND cp.pet_name = 'Cooper'
);
INSERT INTO customer_pet (customer_id, pet_name, species, breed, image_url)
SELECT c.customerId, 'Mittens', 'cat', 'Siamese', NULL
FROM customer c
WHERE c.email = 'sophia@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM customer_pet cp
WHERE cp.customer_id = c.customerId AND cp.pet_name = 'Mittens'
);

View File

@@ -2,6 +2,7 @@ package com.petshop.backend.controller;
import com.petshop.backend.entity.Employee;
import com.petshop.backend.entity.EmployeeStore;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.CategoryRepository;
@@ -71,10 +72,12 @@ class DropdownControllerTest {
User staffUser = new User();
staffUser.setId(7L);
staffUser.setRole(User.Role.STAFF);
staffUser.setActive(true);
User adminUser = new User();
adminUser.setId(8L);
adminUser.setRole(User.Role.ADMIN);
adminUser.setActive(true);
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(staffEmployee, store), new EmployeeStore(adminEmployee, store)));
@@ -87,4 +90,99 @@ class DropdownControllerTest {
assertEquals(Long.valueOf(7L), response.getBody().get(0).getId());
assertEquals("Alex Jones", response.getBody().get(0).getLabel());
}
@Test
void getStoreEmployeesExcludesInactiveStaffUsers() {
PetRepository petRepository = mock(PetRepository.class);
CustomerRepository customerRepository = mock(CustomerRepository.class);
CustomerPetRepository customerPetRepository = mock(CustomerPetRepository.class);
ServiceRepository serviceRepository = mock(ServiceRepository.class);
ProductRepository productRepository = mock(ProductRepository.class);
CategoryRepository categoryRepository = mock(CategoryRepository.class);
StoreRepository storeRepository = mock(StoreRepository.class);
SupplierRepository supplierRepository = mock(SupplierRepository.class);
EmployeeStoreRepository employeeStoreRepository = mock(EmployeeStoreRepository.class);
UserRepository userRepository = mock(UserRepository.class);
DropdownController controller = new DropdownController(
petRepository,
customerRepository,
customerPetRepository,
serviceRepository,
productRepository,
categoryRepository,
storeRepository,
supplierRepository,
employeeStoreRepository,
userRepository
);
StoreLocation store = new StoreLocation();
store.setStoreId(1L);
Employee inactiveStaffEmployee = new Employee();
inactiveStaffEmployee.setEmployeeId(7L);
inactiveStaffEmployee.setUserId(7L);
inactiveStaffEmployee.setFirstName("Alex");
inactiveStaffEmployee.setLastName("Jones");
inactiveStaffEmployee.setIsActive(true);
User inactiveStaffUser = new User();
inactiveStaffUser.setId(7L);
inactiveStaffUser.setRole(User.Role.STAFF);
inactiveStaffUser.setActive(false);
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(inactiveStaffEmployee, store)));
when(userRepository.findById(7L)).thenReturn(Optional.of(inactiveStaffUser));
var response = controller.getStoreEmployees(1L);
assertEquals(0, response.getBody().size());
}
@Test
void getAppointmentCustomersReturnsOnlyCustomersWithPets() {
PetRepository petRepository = mock(PetRepository.class);
CustomerRepository customerRepository = mock(CustomerRepository.class);
CustomerPetRepository customerPetRepository = mock(CustomerPetRepository.class);
ServiceRepository serviceRepository = mock(ServiceRepository.class);
ProductRepository productRepository = mock(ProductRepository.class);
CategoryRepository categoryRepository = mock(CategoryRepository.class);
StoreRepository storeRepository = mock(StoreRepository.class);
SupplierRepository supplierRepository = mock(SupplierRepository.class);
EmployeeStoreRepository employeeStoreRepository = mock(EmployeeStoreRepository.class);
UserRepository userRepository = mock(UserRepository.class);
DropdownController controller = new DropdownController(
petRepository,
customerRepository,
customerPetRepository,
serviceRepository,
productRepository,
categoryRepository,
storeRepository,
supplierRepository,
employeeStoreRepository,
userRepository
);
Customer one = new Customer();
one.setCustomerId(1L);
one.setFirstName("Alex");
one.setLastName("Brown");
Customer two = new Customer();
two.setCustomerId(2L);
two.setFirstName("Emily");
two.setLastName("Clark");
when(customerRepository.findAllWithPets()).thenReturn(List.of(one, two));
var response = controller.getAppointmentCustomers();
assertEquals(2, response.getBody().size());
assertEquals(Long.valueOf(1L), response.getBody().get(0).getId());
assertEquals("Alex Brown", response.getBody().get(0).getLabel());
}
}

View File

@@ -52,6 +52,7 @@ class AdoptionServiceTest {
pet = new Pet();
pet.setPetId(1L);
pet.setPetName("Buddy");
pet.setPetStatus("Available");
customer = new Customer();
customer.setCustomerId(1L);
@@ -75,11 +76,13 @@ class AdoptionServiceTest {
User staffUser = new User();
staffUser.setId(7L);
staffUser.setRole(User.Role.STAFF);
staffUser.setActive(true);
when(userRepository.findById(7L)).thenReturn(Optional.of(staffUser));
User adminUser = new User();
adminUser.setId(8L);
adminUser.setRole(User.Role.ADMIN);
adminUser.setActive(true);
when(userRepository.findById(8L)).thenReturn(Optional.of(adminUser));
}
@@ -121,4 +124,26 @@ class AdoptionServiceTest {
assertThrows(IllegalArgumentException.class, () -> adoptionService.createAdoption(request));
}
@Test
void createAdoptionRejectsInactiveStaffUserSelection() {
User inactiveStaffUser = new User();
inactiveStaffUser.setId(7L);
inactiveStaffUser.setRole(User.Role.STAFF);
inactiveStaffUser.setActive(false);
when(userRepository.findById(7L)).thenReturn(Optional.of(inactiveStaffUser));
when(petRepository.findById(1L)).thenReturn(Optional.of(pet));
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
when(employeeRepository.findById(7L)).thenReturn(Optional.of(staffEmployee));
AdoptionRequest request = new AdoptionRequest();
request.setPetId(1L);
request.setCustomerId(1L);
request.setEmployeeId(7L);
request.setAdoptionDate(LocalDate.now());
request.setAdoptionStatus("Pending");
assertThrows(IllegalArgumentException.class, () -> adoptionService.createAdoption(request));
}
}

View File

@@ -127,6 +127,7 @@ class AppointmentServiceTest {
User staffUser = new User();
staffUser.setId(7L);
staffUser.setRole(User.Role.STAFF);
staffUser.setActive(true);
when(userRepository.findById(7L)).thenReturn(Optional.of(staffUser));
date = LocalDate.now().plusDays(1);
@@ -215,6 +216,7 @@ class AppointmentServiceTest {
adminUser.setId(99L);
adminUser.setUsername("admin");
adminUser.setRole(User.Role.ADMIN);
adminUser.setActive(true);
adminUser.setTokenVersion(0);
when(userRepository.findById(99L)).thenReturn(Optional.of(adminUser));
setAuthentication(99L, User.Role.ADMIN);
@@ -253,6 +255,7 @@ class AppointmentServiceTest {
adminUser.setId(99L);
adminUser.setUsername("admin");
adminUser.setRole(User.Role.ADMIN);
adminUser.setActive(true);
adminUser.setTokenVersion(0);
when(userRepository.findById(99L)).thenReturn(Optional.of(adminUser));
setAuthentication(99L, User.Role.ADMIN);
@@ -306,6 +309,7 @@ class AppointmentServiceTest {
User adminLinkedUser = new User();
adminLinkedUser.setId(8L);
adminLinkedUser.setRole(User.Role.ADMIN);
adminLinkedUser.setActive(true);
when(userRepository.findById(8L)).thenReturn(Optional.of(adminLinkedUser));
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
@@ -330,6 +334,45 @@ class AppointmentServiceTest {
assertThrows(IllegalArgumentException.class, () -> appointmentService.createAppointment(request));
}
@Test
void createAppointmentRejectsInactiveStaffUserSelection() {
User adminUser = new User();
adminUser.setId(99L);
adminUser.setUsername("admin");
adminUser.setRole(User.Role.ADMIN);
adminUser.setActive(true);
adminUser.setTokenVersion(0);
when(userRepository.findById(99L)).thenReturn(Optional.of(adminUser));
setAuthentication(99L, User.Role.ADMIN);
User inactiveStaffUser = new User();
inactiveStaffUser.setId(7L);
inactiveStaffUser.setRole(User.Role.STAFF);
inactiveStaffUser.setActive(false);
when(userRepository.findById(7L)).thenReturn(Optional.of(inactiveStaffUser));
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(employeeRepository.findById(7L)).thenReturn(Optional.of(employee));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
when(customerPetRepository.findById(11L)).thenReturn(Optional.of(customerPet));
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(employee, store)));
var request = new com.petshop.backend.dto.appointment.AppointmentRequest();
request.setCustomerId(1L);
request.setStoreId(1L);
request.setServiceId(1L);
request.setEmployeeId(7L);
request.setAppointmentDate(date);
request.setAppointmentTime(LocalTime.of(10, 0));
request.setAppointmentStatus("Booked");
request.setCustomerPetIds(List.of(11L));
assertThrows(IllegalArgumentException.class, () -> appointmentService.createAppointment(request));
}
private Appointment appointment(Long id, LocalDate date, LocalTime time, Service service, StoreLocation storeLocation) {
Appointment appointment = new Appointment();
appointment.setAppointmentId(id);