mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 11:15:42 +00:00
Fixed FD-56819 - concurrency issues
This commit is contained in:
72
tests/Feature/Checkouts/Api/AssetCheckoutRaceGuardTest.php
Normal file
72
tests/Feature/Checkouts/Api/AssetCheckoutRaceGuardTest.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Checkouts\Api;
|
||||
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for the concurrent-checkout race reported on
|
||||
* 2026-08-02. Api\AssetsController::checkout() checked availableForCheckout
|
||||
* outside its transaction, then called Asset::checkOut() inside the
|
||||
* transaction with no row lock and no re-check. Two racing requests could
|
||||
* both see the asset as available, both invoke checkOut(), and land
|
||||
* duplicate checkout-history rows plus a doubled checkout_counter.
|
||||
*
|
||||
* The fix moves the availability re-check inside the transaction with
|
||||
* lockForUpdate on the asset row, mirroring the ConsumablesController
|
||||
* pattern from GHSA-x4g2-87xc-m5jm. This test cannot simulate two truly
|
||||
* concurrent HTTP requests in phpunit, but it pins the behavioral
|
||||
* consequence: an asset that has already been assigned mid-flight cannot
|
||||
* be checked out a second time. Any refactor that removes the lock or the
|
||||
* availability re-check would need to preserve this observable behavior.
|
||||
*/
|
||||
class AssetCheckoutRaceGuardTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Event::fake([CheckoutableCheckedOut::class]);
|
||||
}
|
||||
|
||||
public function test_second_checkout_of_already_assigned_asset_does_not_increment_counter()
|
||||
{
|
||||
$firstTarget = User::factory()->create();
|
||||
$asset = Asset::factory()->assignedToUser($firstTarget)->create(['checkout_counter' => 1]);
|
||||
|
||||
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||
->postJson(route('api.asset.checkout', $asset), [
|
||||
'checkout_to_type' => 'user',
|
||||
'assigned_user' => User::factory()->create()->id,
|
||||
])
|
||||
->assertStatusMessageIs('error');
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertSame(1, (int) $asset->checkout_counter, 'checkout_counter must not increment when checkout is refused');
|
||||
$this->assertSame($firstTarget->id, (int) $asset->assigned_to, 'existing assignment must remain intact');
|
||||
}
|
||||
|
||||
public function test_second_checkout_of_already_assigned_asset_does_not_fire_checkout_event()
|
||||
{
|
||||
$firstTarget = User::factory()->create();
|
||||
$asset = Asset::factory()->assignedToUser($firstTarget)->create();
|
||||
|
||||
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||
->postJson(route('api.asset.checkout', $asset), [
|
||||
'checkout_to_type' => 'user',
|
||||
'assigned_user' => User::factory()->create()->id,
|
||||
])
|
||||
->assertStatusMessageIs('error');
|
||||
|
||||
// A racing second checkout that slipped past both the outer and the
|
||||
// locked re-check would fire CheckoutableCheckedOut and generate a
|
||||
// history row + counter bump via the CheckoutableListener chain.
|
||||
// Asserting the event never fires is a proxy for asserting no
|
||||
// downstream side-effects occurred.
|
||||
Event::assertNotDispatched(CheckoutableCheckedOut::class);
|
||||
}
|
||||
}
|
||||
66
tests/Feature/Checkouts/Ui/AssetCheckoutRaceGuardTest.php
Normal file
66
tests/Feature/Checkouts/Ui/AssetCheckoutRaceGuardTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Checkouts\Ui;
|
||||
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for the concurrent-checkout race reported on
|
||||
* 2026-08-02. Assets\AssetCheckoutController::store() checked
|
||||
* availableForCheckout outside its checkOut() call, with no lock and no
|
||||
* re-check. Two racing form submits could both see the asset as available,
|
||||
* both invoke checkOut(), and land duplicate checkout-history rows plus a
|
||||
* doubled checkout_counter.
|
||||
*
|
||||
* The fix wraps checkOut() in a DB::transaction that begins with a
|
||||
* lockForUpdate re-fetch + re-check, mirroring the API fix and the
|
||||
* ConsumablesController pattern from GHSA-x4g2-87xc-m5jm. This test cannot
|
||||
* simulate two truly concurrent form submits in phpunit, but it pins the
|
||||
* behavioral consequence: an asset that has already been assigned
|
||||
* mid-flight cannot be checked out a second time.
|
||||
*/
|
||||
class AssetCheckoutRaceGuardTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Event::fake([CheckoutableCheckedOut::class]);
|
||||
}
|
||||
|
||||
public function test_second_checkout_of_already_assigned_asset_does_not_increment_counter()
|
||||
{
|
||||
$firstTarget = User::factory()->create();
|
||||
$asset = Asset::factory()->assignedToUser($firstTarget)->create(['checkout_counter' => 1]);
|
||||
|
||||
$this->actingAs(User::factory()->superuser()->create())
|
||||
->post(route('hardware.checkout.store', $asset), [
|
||||
'checkout_to_type' => 'user',
|
||||
'assigned_user' => User::factory()->create()->id,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertSame(1, (int) $asset->checkout_counter, 'checkout_counter must not increment when checkout is refused');
|
||||
$this->assertSame($firstTarget->id, (int) $asset->assigned_to, 'existing assignment must remain intact');
|
||||
}
|
||||
|
||||
public function test_second_checkout_of_already_assigned_asset_does_not_fire_checkout_event()
|
||||
{
|
||||
$firstTarget = User::factory()->create();
|
||||
$asset = Asset::factory()->assignedToUser($firstTarget)->create();
|
||||
|
||||
$this->actingAs(User::factory()->superuser()->create())
|
||||
->post(route('hardware.checkout.store', $asset), [
|
||||
'checkout_to_type' => 'user',
|
||||
'assigned_user' => User::factory()->create()->id,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
Event::assertNotDispatched(CheckoutableCheckedOut::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user