From 0afcb79a4138ef4a362e4d0a7331202839f74281 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Tue, 7 Jul 2026 22:08:49 +0100 Subject: [PATCH] fix: prevent purchase of hidden products and price tiers via public checkout --- .../OrderCreateRequestValidationService.php | 68 +++++-- ...rderCreateRequestValidationServiceTest.php | 182 ++++++++++++++++++ 2 files changed, 233 insertions(+), 17 deletions(-) diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index 04b09c163f..b73034b9db 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -9,6 +9,7 @@ use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\Helper\Currency; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; @@ -43,7 +44,7 @@ public function validateRequestData(int $eventId, array $data = []): void $this->validateTypes($data); $event = $this->eventRepository->findById($eventId); - $this->validatePromoCode($eventId, $data); + $promoCode = $this->validatePromoCode($eventId, $data); $this->validateProductSelection($data); $this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService @@ -53,26 +54,30 @@ public function validateRequestData(int $eventId, array $data = []): void ); $this->validateOverallCapacity($data); - $this->validateProductDetails($event, $data); + $this->validateProductDetails($event, $data, $promoCode); } /** * @throws ValidationException */ - private function validatePromoCode(int $eventId, array $data): void + private function validatePromoCode(int $eventId, array $data): ?PromoCodeDomainObject { - if (isset($data['promo_code'])) { - $promoCode = $this->promoCodeRepository->findFirstWhere([ - PromoCodeDomainObjectAbstract::CODE => strtolower(trim($data['promo_code'])), - PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, - ]); + if (!isset($data['promo_code'])) { + return null; + } - if (!$promoCode) { - throw ValidationException::withMessages([ - 'promo_code' => __('This promo code is invalid'), - ]); - } + $promoCode = $this->promoCodeRepository->findFirstWhere([ + PromoCodeDomainObjectAbstract::CODE => strtolower(trim($data['promo_code'])), + PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$promoCode) { + throw ValidationException::withMessages([ + 'promo_code' => __('This promo code is invalid'), + ]); } + + return $promoCode->isValid() ? $promoCode : null; } /** @@ -122,19 +127,19 @@ private function getProducts(array $data): Collection * @throws ValidationException * @throws Exception */ - private function validateProductDetails(EventDomainObject $event, array $data): void + private function validateProductDetails(EventDomainObject $event, array $data, ?PromoCodeDomainObject $promoCode): void { $products = $this->getProducts($data); foreach ($data['products'] as $productIndex => $productAndQuantities) { - $this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products); + $this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products, $promoCode); } } /** * @throws ValidationException */ - private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products): void + private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products, ?PromoCodeDomainObject $promoCode): void { $productId = $productAndQuantities['product_id']; $totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity'); @@ -155,6 +160,11 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro product: $product ); + $this->validateProductVisibility( + product: $product, + promoCode: $promoCode + ); + $this->validateProductQuantity( productIndex: $productIndex, productAndQuantities: $productAndQuantities, @@ -238,6 +248,23 @@ private function validateProductEvent(EventDomainObject $event, int $productId, } } + /** + * Products the organiser has hidden must not be purchasable through the public checkout, even when the + * product ID is known. Products hidden behind a promo code are only purchasable when a valid promo code + * that applies to the product is supplied. This mirrors the display filtering in ProductFilterService. + */ + private function validateProductVisibility(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): void + { + if ($product->getIsHidden()) { + throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId())); + } + + if ($product->getIsHiddenWithoutPromoCode() + && !($promoCode && $promoCode->appliesToProduct($product))) { + throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId())); + } + } + /** * @throws ValidationException */ @@ -292,9 +319,16 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd ]); } - $validPriceIds = $product->getProductPrices()?->map(fn(ProductPriceDomainObject $price) => $price->getId()); + $productPrices = $product->getProductPrices(); + $validPriceIds = $productPrices?->map(fn(ProductPriceDomainObject $price) => $price->getId()); if (!in_array($priceId, $validPriceIds->toArray(), true)) { $errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID'); + continue; + } + + $selectedPrice = $productPrices?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $priceId); + if ((int)$quantity > 0 && $selectedPrice?->getIsHidden()) { + $errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID'); } } diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php index 65e772e45e..31b501da22 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -7,6 +7,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; @@ -19,6 +20,7 @@ use Illuminate\Validation\ValidationException; use Mockery; use Mockery\MockInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Tests\TestCase; class OrderCreateRequestValidationServiceTest extends TestCase @@ -235,6 +237,180 @@ public function testUnrelatedOverReservedCapacityDoesNotBlockSelectedProduct(): $this->assertTrue(true); } + public function testHiddenProductIsRejected(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Hidden VIP'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + isHidden: true, + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $priceId, 'quantity' => 1], + ], + ], + ], + ]; + + $this->expectException(NotFoundHttpException::class); + $this->service->validateRequestData($eventId, $data); + } + + public function testProductHiddenWithoutPromoCodeIsRejectedWhenNoPromoCodeSupplied(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Promo Only'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + isHiddenWithoutPromoCode: true, + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $priceId, 'quantity' => 1], + ], + ], + ], + ]; + + $this->expectException(NotFoundHttpException::class); + $this->service->validateRequestData($eventId, $data); + } + + public function testProductHiddenWithoutPromoCodeIsAllowedWithMatchingPromoCode(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Promo Only'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + isHiddenWithoutPromoCode: true, + ); + + $promoCode = Mockery::mock(PromoCodeDomainObject::class); + $promoCode->shouldReceive('isValid')->andReturn(true); + $promoCode->shouldReceive('appliesToProduct')->andReturn(true); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode); + + $data = [ + 'promo_code' => 'UNLOCK', + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $priceId, 'quantity' => 1], + ], + ], + ], + ]; + + $this->service->validateRequestData($eventId, $data); + $this->assertTrue(true); + } + + public function testProductHiddenWithoutPromoCodeIsRejectedWhenPromoCodeDoesNotApply(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Promo Only'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + isHiddenWithoutPromoCode: true, + ); + + $promoCode = Mockery::mock(PromoCodeDomainObject::class); + $promoCode->shouldReceive('isValid')->andReturn(true); + $promoCode->shouldReceive('appliesToProduct')->andReturn(false); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode); + + $data = [ + 'promo_code' => 'WRONGPRODUCT', + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $priceId, 'quantity' => 1], + ], + ], + ], + ]; + + $this->expectException(NotFoundHttpException::class); + $this->service->validateRequestData($eventId, $data); + } + + public function testHiddenPriceTierIsRejected(): void + { + $eventId = 1; + $productId = 10; + $visiblePriceId = 101; + $hiddenPriceId = 102; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$visiblePriceId, $hiddenPriceId], + priceLabels: ['General', 'Hidden VIP'], + availabilities: [ + ['price_id' => $visiblePriceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ['price_id' => $hiddenPriceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + hiddenPriceIds: [$hiddenPriceId], + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $hiddenPriceId, 'quantity' => 1], + ], + ], + ], + ]; + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $data); + } + private function setupMocks( int $eventId, int $productId, @@ -243,6 +419,9 @@ private function setupMocks( array $availabilities, ?Collection $capacities = null, array $extraAvailabilities = [], + bool $isHidden = false, + bool $isHiddenWithoutPromoCode = false, + array $hiddenPriceIds = [], ): void { $event = Mockery::mock(EventDomainObject::class); @@ -257,6 +436,7 @@ private function setupMocks( $price = Mockery::mock(ProductPriceDomainObject::class); $price->shouldReceive('getId')->andReturn($priceId); $price->shouldReceive('getLabel')->andReturn($priceLabels[$i] ?? null); + $price->shouldReceive('getIsHidden')->andReturn(in_array($priceId, $hiddenPriceIds, true)); $productPrices->push($price); } @@ -269,6 +449,8 @@ private function setupMocks( $product->shouldReceive('isSoldOut')->andReturn(false); $product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name); $product->shouldReceive('getProductPrices')->andReturn($productPrices); + $product->shouldReceive('getIsHidden')->andReturn($isHidden); + $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn($isHiddenWithoutPromoCode); $this->productRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$product]));