3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/tests/Feature/Checkouts/Api/AssetCheckoutRaceGuardTest.php
2026-08-02 11:39:08 +01:00

73 lines
3.0 KiB
PHP

<?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);
}
}