Compare commits
4 Commits
fix/ci-pod
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c307f8271e | ||
|
|
26bf7ac377 | ||
|
|
ac1084d6fe | ||
|
|
1e2a579c4f |
@@ -74,6 +74,7 @@ class CartController extends Controller
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->addItem(
|
||||
$business,
|
||||
$request->integer('product_id'),
|
||||
$request->integer('quantity', 1),
|
||||
$user,
|
||||
@@ -110,7 +111,7 @@ class CartController extends Controller
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'));
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'), $user, $sessionId);
|
||||
|
||||
// Ensure product is loaded for JSON response
|
||||
$cart->load('product', 'brand');
|
||||
@@ -141,11 +142,11 @@ class CartController extends Controller
|
||||
*/
|
||||
public function remove(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
{
|
||||
$this->cartService->removeItem($cartId);
|
||||
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
$this->cartService->removeItem($cartId, $user, $sessionId);
|
||||
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
|
||||
@@ -17,6 +17,7 @@ class Cart extends Model
|
||||
'product_id',
|
||||
'batch_id',
|
||||
'brand_id',
|
||||
'business_id',
|
||||
'quantity',
|
||||
'session_id',
|
||||
];
|
||||
@@ -57,6 +58,14 @@ class Cart extends Model
|
||||
return $this->belongsTo(Batch::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the business associated with this cart item.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the line total (quantity * product price).
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services;
|
||||
|
||||
use App\Events\CartUpdated;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Cart;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
@@ -44,12 +45,36 @@ class CartService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cart ownership - ensures the cart belongs to the current user/session.
|
||||
*
|
||||
* SECURITY: Prevents users from modifying other users' cart items.
|
||||
*
|
||||
* @throws \Exception if cart doesn't belong to user/session
|
||||
*/
|
||||
private function verifyCartOwnership(Cart $cart, ?User $user, ?string $sessionId): void
|
||||
{
|
||||
if ($user) {
|
||||
// Authenticated user: cart must belong to this user
|
||||
if ($cart->user_id !== $user->id) {
|
||||
throw new \Exception('Unauthorized: This cart item does not belong to you.');
|
||||
}
|
||||
} else {
|
||||
// Guest: cart must belong to this session
|
||||
if ($cart->session_id !== $sessionId) {
|
||||
throw new \Exception('Unauthorized: This cart item does not belong to your session.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to cart (or update quantity if exists).
|
||||
*
|
||||
* SECURITY: Stores business_id for cart authorization and audit trail.
|
||||
*
|
||||
* @throws \Exception if quantity exceeds available stock
|
||||
*/
|
||||
public function addItem(int $productId, int $quantity = 1, ?User $user = null, ?string $sessionId = null, ?int $batchId = null): Cart
|
||||
public function addItem(Business $business, int $productId, int $quantity = 1, ?User $user = null, ?string $sessionId = null, ?int $batchId = null): Cart
|
||||
{
|
||||
$product = Product::findOrFail($productId);
|
||||
$batch = null;
|
||||
@@ -104,6 +129,7 @@ class CartService
|
||||
'product_id' => $productId,
|
||||
'batch_id' => $batchId,
|
||||
'brand_id' => $product->brand_id,
|
||||
'business_id' => $business->id,
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
|
||||
@@ -133,12 +159,17 @@ class CartService
|
||||
/**
|
||||
* Update cart item quantity.
|
||||
*
|
||||
* @throws \Exception if quantity exceeds available stock
|
||||
* SECURITY: Verifies cart ownership before allowing updates.
|
||||
*
|
||||
* @throws \Exception if quantity exceeds available stock or cart doesn't belong to user
|
||||
*/
|
||||
public function updateQuantity(int $cartId, int $quantity): Cart
|
||||
public function updateQuantity(int $cartId, int $quantity, ?User $user = null, ?string $sessionId = null): Cart
|
||||
{
|
||||
$cart = Cart::with('product', 'user')->findOrFail($cartId);
|
||||
|
||||
// SECURITY: Verify this cart belongs to the current user/session
|
||||
$this->verifyCartOwnership($cart, $user, $sessionId);
|
||||
|
||||
// Ensure at least 1
|
||||
$quantity = max(1, $quantity);
|
||||
|
||||
@@ -160,11 +191,18 @@ class CartService
|
||||
|
||||
/**
|
||||
* Remove item from cart.
|
||||
*
|
||||
* SECURITY: Verifies cart ownership before allowing deletion.
|
||||
*
|
||||
* @throws \Exception if cart doesn't belong to user
|
||||
*/
|
||||
public function removeItem(int $cartId): bool
|
||||
public function removeItem(int $cartId, ?User $user = null, ?string $sessionId = null): bool
|
||||
{
|
||||
$cart = Cart::with('user')->findOrFail($cartId);
|
||||
|
||||
// SECURITY: Verify this cart belongs to the current user/session
|
||||
$this->verifyCartOwnership($cart, $user, $sessionId);
|
||||
|
||||
// Clear cached cart count before deleting
|
||||
$this->clearCartCache($cart->user, $cart->session_id);
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('carts', function (Blueprint $table) {
|
||||
// Add business_id column (nullable initially to allow backfill)
|
||||
$table->foreignId('business_id')->nullable()->after('brand_id')->constrained('businesses')->cascadeOnDelete();
|
||||
$table->index('business_id');
|
||||
});
|
||||
|
||||
// Backfill business_id from brand relationship for existing cart records
|
||||
// This ensures existing cart items get the correct business_id
|
||||
\Illuminate\Support\Facades\DB::statement('
|
||||
UPDATE carts
|
||||
SET business_id = (
|
||||
SELECT business_id
|
||||
FROM brands
|
||||
WHERE brands.id = carts.brand_id
|
||||
)
|
||||
WHERE brand_id IS NOT NULL
|
||||
');
|
||||
|
||||
// Make business_id NOT NULL after backfill (all records should now have business_id)
|
||||
Schema::table('carts', function (Blueprint $table) {
|
||||
$table->foreignId('business_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('carts', function (Blueprint $table) {
|
||||
$table->dropForeign(['business_id']);
|
||||
$table->dropIndex(['business_id']);
|
||||
$table->dropColumn('business_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Events\CartUpdated;
|
||||
use App\Models\Business;
|
||||
use App\Models\Cart;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
@@ -9,7 +10,9 @@ use Illuminate\Support\Facades\Event;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->cartService = app(CartService::class);
|
||||
$this->user = User::factory()->create();
|
||||
$this->business = Business::factory()->create(['type' => 'buyer']);
|
||||
$this->user = User::factory()->create(['user_type' => 'buyer']);
|
||||
$this->user->businesses()->attach($this->business->id);
|
||||
$this->product = Product::factory()->create([
|
||||
'quantity_on_hand' => 100,
|
||||
'quantity_allocated' => 0,
|
||||
@@ -22,6 +25,7 @@ test('cart service broadcasts CartUpdated event when adding item', function () {
|
||||
|
||||
// Add item to cart
|
||||
$this->cartService->addItem(
|
||||
business: $this->business,
|
||||
productId: $this->product->id,
|
||||
quantity: 1,
|
||||
user: $this->user
|
||||
@@ -39,6 +43,7 @@ test('cart service broadcasts CartUpdated event when updating quantity', functio
|
||||
|
||||
// Add item first
|
||||
$cart = $this->cartService->addItem(
|
||||
business: $this->business,
|
||||
productId: $this->product->id,
|
||||
quantity: 1,
|
||||
user: $this->user
|
||||
@@ -48,7 +53,7 @@ test('cart service broadcasts CartUpdated event when updating quantity', functio
|
||||
Event::fake([CartUpdated::class]);
|
||||
|
||||
// Update quantity
|
||||
$this->cartService->updateQuantity($cart->id, 5);
|
||||
$this->cartService->updateQuantity($cart->id, 5, $this->user, null);
|
||||
|
||||
// Assert CartUpdated event was dispatched with new count
|
||||
Event::assertDispatched(CartUpdated::class, function ($event) {
|
||||
@@ -60,6 +65,7 @@ test('cart service broadcasts CartUpdated event when updating quantity', functio
|
||||
test('cart service broadcasts CartUpdated event when removing item', function () {
|
||||
// Add item first WITHOUT faking events
|
||||
$cart = $this->cartService->addItem(
|
||||
business: $this->business,
|
||||
productId: $this->product->id,
|
||||
quantity: 1,
|
||||
user: $this->user
|
||||
@@ -69,7 +75,7 @@ test('cart service broadcasts CartUpdated event when removing item', function ()
|
||||
Event::fake([CartUpdated::class]);
|
||||
|
||||
// Remove item
|
||||
$this->cartService->removeItem($cart->id);
|
||||
$this->cartService->removeItem($cart->id, $this->user, null);
|
||||
|
||||
// Assert CartUpdated event was dispatched with count 0
|
||||
Event::assertDispatched(CartUpdated::class, function ($event) {
|
||||
@@ -81,6 +87,7 @@ test('cart service broadcasts CartUpdated event when removing item', function ()
|
||||
test('cart service broadcasts CartUpdated event when clearing cart', function () {
|
||||
// Add multiple items WITHOUT faking events
|
||||
$this->cartService->addItem(
|
||||
business: $this->business,
|
||||
productId: $this->product->id,
|
||||
quantity: 3,
|
||||
user: $this->user
|
||||
@@ -91,6 +98,7 @@ test('cart service broadcasts CartUpdated event when clearing cart', function ()
|
||||
'quantity_allocated' => 0,
|
||||
]);
|
||||
$this->cartService->addItem(
|
||||
business: $this->business,
|
||||
productId: $product2->id,
|
||||
quantity: 2,
|
||||
user: $this->user
|
||||
@@ -114,6 +122,7 @@ test('cart service does not broadcast for guest cart operations', function () {
|
||||
|
||||
// Add item with session ID (guest cart)
|
||||
$this->cartService->addItem(
|
||||
business: $this->business,
|
||||
productId: $this->product->id,
|
||||
quantity: 1,
|
||||
user: null,
|
||||
|
||||
199
tests/Feature/CartBusinessAuthorizationTest.php
Normal file
199
tests/Feature/CartBusinessAuthorizationTest.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Cart;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use App\Services\CartService;
|
||||
|
||||
/**
|
||||
* Security tests for cart business authorization.
|
||||
*
|
||||
* Tests that cart operations properly enforce business_id scoping
|
||||
* and prevent cross-user cart manipulation.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->cartService = app(CartService::class);
|
||||
|
||||
// Create buyer business and user
|
||||
$this->buyerBusiness = Business::factory()->create(['type' => 'buyer']);
|
||||
$this->buyerUser = User::factory()->create(['user_type' => 'buyer']);
|
||||
$this->buyerUser->businesses()->attach($this->buyerBusiness->id);
|
||||
|
||||
// Create another buyer business and user for cross-user tests
|
||||
$this->otherBusiness = Business::factory()->create(['type' => 'buyer']);
|
||||
$this->otherUser = User::factory()->create(['user_type' => 'buyer']);
|
||||
$this->otherUser->businesses()->attach($this->otherBusiness->id);
|
||||
|
||||
// Create seller business with a product
|
||||
$sellerBusiness = Business::factory()->create(['type' => 'seller']);
|
||||
$brand = Brand::factory()->create(['business_id' => $sellerBusiness->id]);
|
||||
$this->product = Product::factory()->create([
|
||||
'brand_id' => $brand->id,
|
||||
'wholesale_price' => 100.00,
|
||||
'quantity_on_hand' => 100,
|
||||
'quantity_allocated' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
test('cart item stores business_id when added', function () {
|
||||
$this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
2,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('carts', [
|
||||
'user_id' => $this->buyerUser->id,
|
||||
'product_id' => $this->product->id,
|
||||
'business_id' => $this->buyerBusiness->id,
|
||||
'quantity' => 2,
|
||||
]);
|
||||
});
|
||||
|
||||
test('user cannot update another users cart item', function () {
|
||||
// Add item to first user's cart
|
||||
$cart = $this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
2,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
// Try to update it as another user - should throw exception
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Unauthorized: This cart item does not belong to you.');
|
||||
|
||||
$this->cartService->updateQuantity(
|
||||
$cart->id,
|
||||
5,
|
||||
$this->otherUser,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('user cannot remove another users cart item', function () {
|
||||
// Add item to first user's cart
|
||||
$cart = $this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
2,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
// Try to remove it as another user - should throw exception
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Unauthorized: This cart item does not belong to you.');
|
||||
|
||||
$this->cartService->removeItem(
|
||||
$cart->id,
|
||||
$this->otherUser,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('guest session cannot modify another sessions cart', function () {
|
||||
$sessionId1 = 'session_abc123';
|
||||
$sessionId2 = 'session_xyz789';
|
||||
|
||||
// Add item with first session
|
||||
$cart = $this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
2,
|
||||
null,
|
||||
$sessionId1
|
||||
);
|
||||
|
||||
// Try to update with different session - should throw exception
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Unauthorized: This cart item does not belong to your session.');
|
||||
|
||||
$this->cartService->updateQuantity(
|
||||
$cart->id,
|
||||
5,
|
||||
null,
|
||||
$sessionId2
|
||||
);
|
||||
});
|
||||
|
||||
test('cart operations are scoped to correct business', function () {
|
||||
// Add item with buyer business
|
||||
$this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
3,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
// Verify cart has correct business_id
|
||||
$cartItem = Cart::where('user_id', $this->buyerUser->id)->first();
|
||||
|
||||
expect($cartItem)->not->toBeNull();
|
||||
expect($cartItem->business_id)->toBe($this->buyerBusiness->id);
|
||||
expect($cartItem->product_id)->toBe($this->product->id);
|
||||
});
|
||||
|
||||
test('cart item includes business relationship', function () {
|
||||
$this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
1,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
$cartItem = Cart::with('business')->where('user_id', $this->buyerUser->id)->first();
|
||||
|
||||
expect($cartItem->business)->not->toBeNull();
|
||||
expect($cartItem->business->id)->toBe($this->buyerBusiness->id);
|
||||
expect($cartItem->business->name)->toBe($this->buyerBusiness->name);
|
||||
});
|
||||
|
||||
test('authenticated user can update their own cart', function () {
|
||||
$cart = $this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
2,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
// Update with same user - should succeed
|
||||
$updatedCart = $this->cartService->updateQuantity(
|
||||
$cart->id,
|
||||
5,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
expect($updatedCart->quantity)->toBe(5);
|
||||
});
|
||||
|
||||
test('authenticated user can remove their own cart item', function () {
|
||||
$cart = $this->cartService->addItem(
|
||||
$this->buyerBusiness,
|
||||
$this->product->id,
|
||||
2,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
// Remove with same user - should succeed
|
||||
$result = $this->cartService->removeItem(
|
||||
$cart->id,
|
||||
$this->buyerUser,
|
||||
null
|
||||
);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
$this->assertDatabaseMissing('carts', ['id' => $cart->id]);
|
||||
});
|
||||
Reference in New Issue
Block a user