From 4a4acb2214bf208e3f755ff6544f109615828ee5 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Fri, 9 Oct 2026 12:58:06 +0200 Subject: [PATCH] Lock creation of running time entries to prevent race condition Concurrent requests could both pass the running time entry check and create more than one running time entry for the same member. --- .../Api/V1/TimeEntryController.php | 17 +++++++ .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 9c77b2c4..5c44ee50 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -51,6 +51,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -613,6 +614,22 @@ class TimeEntryController extends Controller $this->checkPermission($organization, 'time-entries:create:all'); } + // Lock the creation of running time entries per user, so that concurrent requests can not create more than one running time entry + $lock = $request->input('end') === null ? Cache::lock('time-entries:running:'.$member->user_id, 10) : null; + $lock?->block(5); + + try { + return $this->storeTimeEntry($organization, $member, $request); + } finally { + $lock?->release(); + } + } + + /** + * @throws TimeEntryStillRunningApiException + */ + private function storeTimeEntry(Organization $organization, Member $member, TimeEntryStoreRequest $request): JsonResource + { if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { throw new TimeEntryStillRunningApiException; } diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 733693cb..8d9b349d 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -24,7 +24,9 @@ use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; use App\Service\TimeEntryFilter; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -2444,6 +2446,52 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $response->assertJsonPath('error', true); } + public function test_store_endpoint_releases_running_time_entry_lock_after_request(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'billable' => true, + 'start' => Carbon::now()->toIso8601ZuluString(), + 'end' => null, + 'member_id' => $data->member->getKey(), + ]); + + // Assert + $response->assertStatus(201); + $this->assertTrue(Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get()); + } + + public function test_store_endpoint_waits_for_running_time_entry_lock_and_fails_if_it_is_not_released(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get(); + $this->withoutExceptionHandling(); + Passport::actingAs($data->user); + + // Act + try { + $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'billable' => true, + 'start' => Carbon::now()->toIso8601ZuluString(), + 'end' => null, + 'member_id' => $data->member->getKey(), + ]); + $this->fail('Expected LockTimeoutException'); + } catch (LockTimeoutException) { + // Assert + $this->assertSame(0, TimeEntry::query()->count()); + } + } + public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void { // Arrange