Skip to content

Commit 3fff3ce

Browse files
committed
fix: prevent purchase of hidden products via public checkout (#1259)
1 parent 7c984dc commit 3fff3ce

2 files changed

Lines changed: 233 additions & 17 deletions

File tree

backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
1010
use HiEvents\DomainObjects\ProductDomainObject;
1111
use HiEvents\DomainObjects\ProductPriceDomainObject;
12+
use HiEvents\DomainObjects\PromoCodeDomainObject;
1213
use HiEvents\Helper\Currency;
1314
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
1415
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
@@ -43,7 +44,7 @@ public function validateRequestData(int $eventId, array $data = []): void
4344
$this->validateTypes($data);
4445

4546
$event = $this->eventRepository->findById($eventId);
46-
$this->validatePromoCode($eventId, $data);
47+
$promoCode = $this->validatePromoCode($eventId, $data);
4748
$this->validateProductSelection($data);
4849

4950
$this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService
@@ -53,26 +54,30 @@ public function validateRequestData(int $eventId, array $data = []): void
5354
);
5455

5556
$this->validateOverallCapacity($data);
56-
$this->validateProductDetails($event, $data);
57+
$this->validateProductDetails($event, $data, $promoCode);
5758
}
5859

5960
/**
6061
* @throws ValidationException
6162
*/
62-
private function validatePromoCode(int $eventId, array $data): void
63+
private function validatePromoCode(int $eventId, array $data): ?PromoCodeDomainObject
6364
{
64-
if (isset($data['promo_code'])) {
65-
$promoCode = $this->promoCodeRepository->findFirstWhere([
66-
PromoCodeDomainObjectAbstract::CODE => strtolower(trim($data['promo_code'])),
67-
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
68-
]);
65+
if (!isset($data['promo_code'])) {
66+
return null;
67+
}
6968

70-
if (!$promoCode) {
71-
throw ValidationException::withMessages([
72-
'promo_code' => __('This promo code is invalid'),
73-
]);
74-
}
69+
$promoCode = $this->promoCodeRepository->findFirstWhere([
70+
PromoCodeDomainObjectAbstract::CODE => strtolower(trim($data['promo_code'])),
71+
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
72+
]);
73+
74+
if (!$promoCode) {
75+
throw ValidationException::withMessages([
76+
'promo_code' => __('This promo code is invalid'),
77+
]);
7578
}
79+
80+
return $promoCode->isValid() ? $promoCode : null;
7681
}
7782

7883
/**
@@ -122,19 +127,19 @@ private function getProducts(array $data): Collection
122127
* @throws ValidationException
123128
* @throws Exception
124129
*/
125-
private function validateProductDetails(EventDomainObject $event, array $data): void
130+
private function validateProductDetails(EventDomainObject $event, array $data, ?PromoCodeDomainObject $promoCode): void
126131
{
127132
$products = $this->getProducts($data);
128133

129134
foreach ($data['products'] as $productIndex => $productAndQuantities) {
130-
$this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products);
135+
$this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products, $promoCode);
131136
}
132137
}
133138

134139
/**
135140
* @throws ValidationException
136141
*/
137-
private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products): void
142+
private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products, ?PromoCodeDomainObject $promoCode): void
138143
{
139144
$productId = $productAndQuantities['product_id'];
140145
$totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity');
@@ -155,6 +160,11 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
155160
product: $product
156161
);
157162

163+
$this->validateProductVisibility(
164+
product: $product,
165+
promoCode: $promoCode
166+
);
167+
158168
$this->validateProductQuantity(
159169
productIndex: $productIndex,
160170
productAndQuantities: $productAndQuantities,
@@ -238,6 +248,23 @@ private function validateProductEvent(EventDomainObject $event, int $productId,
238248
}
239249
}
240250

251+
/**
252+
* Products the organiser has hidden must not be purchasable through the public checkout, even when the
253+
* product ID is known. Products hidden behind a promo code are only purchasable when a valid promo code
254+
* that applies to the product is supplied. This mirrors the display filtering in ProductFilterService.
255+
*/
256+
private function validateProductVisibility(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): void
257+
{
258+
if ($product->getIsHidden()) {
259+
throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
260+
}
261+
262+
if ($product->getIsHiddenWithoutPromoCode()
263+
&& !($promoCode && $promoCode->appliesToProduct($product))) {
264+
throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
265+
}
266+
}
267+
241268
/**
242269
* @throws ValidationException
243270
*/
@@ -292,9 +319,16 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd
292319
]);
293320
}
294321

295-
$validPriceIds = $product->getProductPrices()?->map(fn(ProductPriceDomainObject $price) => $price->getId());
322+
$productPrices = $product->getProductPrices();
323+
$validPriceIds = $productPrices?->map(fn(ProductPriceDomainObject $price) => $price->getId());
296324
if (!in_array($priceId, $validPriceIds->toArray(), true)) {
297325
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
326+
continue;
327+
}
328+
329+
$selectedPrice = $productPrices?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $priceId);
330+
if ((int)$quantity > 0 && $selectedPrice?->getIsHidden()) {
331+
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
298332
}
299333
}
300334

backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use HiEvents\DomainObjects\EventDomainObject;
88
use HiEvents\DomainObjects\ProductDomainObject;
99
use HiEvents\DomainObjects\ProductPriceDomainObject;
10+
use HiEvents\DomainObjects\PromoCodeDomainObject;
1011
use HiEvents\DomainObjects\Status\EventStatus;
1112
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
1213
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
@@ -19,6 +20,7 @@
1920
use Illuminate\Validation\ValidationException;
2021
use Mockery;
2122
use Mockery\MockInterface;
23+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2224
use Tests\TestCase;
2325

2426
class OrderCreateRequestValidationServiceTest extends TestCase
@@ -235,6 +237,180 @@ public function testUnrelatedOverReservedCapacityDoesNotBlockSelectedProduct():
235237
$this->assertTrue(true);
236238
}
237239

240+
public function testHiddenProductIsRejected(): void
241+
{
242+
$eventId = 1;
243+
$productId = 10;
244+
$priceId = 101;
245+
246+
$this->setupMocks(
247+
eventId: $eventId,
248+
productId: $productId,
249+
priceIds: [$priceId],
250+
priceLabels: ['Hidden VIP'],
251+
availabilities: [
252+
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
253+
],
254+
isHidden: true,
255+
);
256+
257+
$data = [
258+
'products' => [
259+
[
260+
'product_id' => $productId,
261+
'quantities' => [
262+
['price_id' => $priceId, 'quantity' => 1],
263+
],
264+
],
265+
],
266+
];
267+
268+
$this->expectException(NotFoundHttpException::class);
269+
$this->service->validateRequestData($eventId, $data);
270+
}
271+
272+
public function testProductHiddenWithoutPromoCodeIsRejectedWhenNoPromoCodeSupplied(): void
273+
{
274+
$eventId = 1;
275+
$productId = 10;
276+
$priceId = 101;
277+
278+
$this->setupMocks(
279+
eventId: $eventId,
280+
productId: $productId,
281+
priceIds: [$priceId],
282+
priceLabels: ['Promo Only'],
283+
availabilities: [
284+
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
285+
],
286+
isHiddenWithoutPromoCode: true,
287+
);
288+
289+
$data = [
290+
'products' => [
291+
[
292+
'product_id' => $productId,
293+
'quantities' => [
294+
['price_id' => $priceId, 'quantity' => 1],
295+
],
296+
],
297+
],
298+
];
299+
300+
$this->expectException(NotFoundHttpException::class);
301+
$this->service->validateRequestData($eventId, $data);
302+
}
303+
304+
public function testProductHiddenWithoutPromoCodeIsAllowedWithMatchingPromoCode(): void
305+
{
306+
$eventId = 1;
307+
$productId = 10;
308+
$priceId = 101;
309+
310+
$this->setupMocks(
311+
eventId: $eventId,
312+
productId: $productId,
313+
priceIds: [$priceId],
314+
priceLabels: ['Promo Only'],
315+
availabilities: [
316+
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
317+
],
318+
isHiddenWithoutPromoCode: true,
319+
);
320+
321+
$promoCode = Mockery::mock(PromoCodeDomainObject::class);
322+
$promoCode->shouldReceive('isValid')->andReturn(true);
323+
$promoCode->shouldReceive('appliesToProduct')->andReturn(true);
324+
$this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode);
325+
326+
$data = [
327+
'promo_code' => 'UNLOCK',
328+
'products' => [
329+
[
330+
'product_id' => $productId,
331+
'quantities' => [
332+
['price_id' => $priceId, 'quantity' => 1],
333+
],
334+
],
335+
],
336+
];
337+
338+
$this->service->validateRequestData($eventId, $data);
339+
$this->assertTrue(true);
340+
}
341+
342+
public function testProductHiddenWithoutPromoCodeIsRejectedWhenPromoCodeDoesNotApply(): void
343+
{
344+
$eventId = 1;
345+
$productId = 10;
346+
$priceId = 101;
347+
348+
$this->setupMocks(
349+
eventId: $eventId,
350+
productId: $productId,
351+
priceIds: [$priceId],
352+
priceLabels: ['Promo Only'],
353+
availabilities: [
354+
['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
355+
],
356+
isHiddenWithoutPromoCode: true,
357+
);
358+
359+
$promoCode = Mockery::mock(PromoCodeDomainObject::class);
360+
$promoCode->shouldReceive('isValid')->andReturn(true);
361+
$promoCode->shouldReceive('appliesToProduct')->andReturn(false);
362+
$this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode);
363+
364+
$data = [
365+
'promo_code' => 'WRONGPRODUCT',
366+
'products' => [
367+
[
368+
'product_id' => $productId,
369+
'quantities' => [
370+
['price_id' => $priceId, 'quantity' => 1],
371+
],
372+
],
373+
],
374+
];
375+
376+
$this->expectException(NotFoundHttpException::class);
377+
$this->service->validateRequestData($eventId, $data);
378+
}
379+
380+
public function testHiddenPriceTierIsRejected(): void
381+
{
382+
$eventId = 1;
383+
$productId = 10;
384+
$visiblePriceId = 101;
385+
$hiddenPriceId = 102;
386+
387+
$this->setupMocks(
388+
eventId: $eventId,
389+
productId: $productId,
390+
priceIds: [$visiblePriceId, $hiddenPriceId],
391+
priceLabels: ['General', 'Hidden VIP'],
392+
availabilities: [
393+
['price_id' => $visiblePriceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
394+
['price_id' => $hiddenPriceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
395+
],
396+
hiddenPriceIds: [$hiddenPriceId],
397+
);
398+
399+
$data = [
400+
'products' => [
401+
[
402+
'product_id' => $productId,
403+
'quantities' => [
404+
['price_id' => $hiddenPriceId, 'quantity' => 1],
405+
],
406+
],
407+
],
408+
];
409+
410+
$this->expectException(ValidationException::class);
411+
$this->service->validateRequestData($eventId, $data);
412+
}
413+
238414
private function setupMocks(
239415
int $eventId,
240416
int $productId,
@@ -243,6 +419,9 @@ private function setupMocks(
243419
array $availabilities,
244420
?Collection $capacities = null,
245421
array $extraAvailabilities = [],
422+
bool $isHidden = false,
423+
bool $isHiddenWithoutPromoCode = false,
424+
array $hiddenPriceIds = [],
246425
): void
247426
{
248427
$event = Mockery::mock(EventDomainObject::class);
@@ -257,6 +436,7 @@ private function setupMocks(
257436
$price = Mockery::mock(ProductPriceDomainObject::class);
258437
$price->shouldReceive('getId')->andReturn($priceId);
259438
$price->shouldReceive('getLabel')->andReturn($priceLabels[$i] ?? null);
439+
$price->shouldReceive('getIsHidden')->andReturn(in_array($priceId, $hiddenPriceIds, true));
260440
$productPrices->push($price);
261441
}
262442

@@ -269,6 +449,8 @@ private function setupMocks(
269449
$product->shouldReceive('isSoldOut')->andReturn(false);
270450
$product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name);
271451
$product->shouldReceive('getProductPrices')->andReturn($productPrices);
452+
$product->shouldReceive('getIsHidden')->andReturn($isHidden);
453+
$product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn($isHiddenWithoutPromoCode);
272454

273455
$this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
274456
$this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$product]));

0 commit comments

Comments
 (0)