Compare commits

...

4 Commits

Author SHA1 Message Date
kelly
c307f8271e Merge branch 'develop' into feature/implement-laravel-daisyui 2025-11-05 02:00:57 +00:00
Jon
26bf7ac377 Merge pull request 'Security Fix: Cart Business Authorization' (#26) from feature/fix-cart-business-authorization into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/26
2025-11-05 00:28:45 +00:00
Jon Leopard
ac1084d6fe test: add security tests for cart business authorization
Add comprehensive security test suite to validate:
- business_id storage in cart records
- Cross-user cart modification prevention
- Cross-session cart manipulation prevention
- Business scoping enforcement
- Cart ownership verification

8 new tests with 16 assertions ensure cart operations are
properly isolated by business and user/session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 15:18:22 -07:00
Jon Leopard
1e2a579c4f feat: add business_id to Cart table for security isolation
Add business_id column to carts table with:
- Foreign key constraint to businesses table
- Index for query performance
- Backfill logic from brands.business_id
- business() relationship in Cart model

This enables proper business scoping and audit trails for cart
operations, required for cannabis compliance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 15:17:52 -07:00
6 changed files with 315 additions and 10 deletions

View File

@@ -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);

View File

@@ -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).
*/

View File

@@ -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);

View File

@@ -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');
});
}
};

View File

@@ -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,

View 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]);
});