From 633884f45f540b575757434f5624b8e04fca375a Mon Sep 17 00:00:00 2001 From: Kofi Date: Mon, 29 Sep 2025 20:35:31 +0100 Subject: [PATCH] fix: Prevent backdated updated at --- .../Controllers/API/v1/CategoryController.php | 8 +++++++ .../Controllers/API/v1/GroupController.php | 8 +++++++ .../Controllers/API/v1/PartyController.php | 8 +++++++ .../API/v1/TransactionController.php | 2 ++ .../Controllers/API/v1/WalletController.php | 8 +++++++ app/Http/Traits/ApiQueryable.php | 13 ++++++++++ public/docs/api.json | 16 +++++++++++++ tests/Feature/SyncableModelsTest.php | 24 +++++++++++++++++++ 8 files changed, 87 insertions(+) diff --git a/app/Http/Controllers/API/v1/CategoryController.php b/app/Http/Controllers/API/v1/CategoryController.php index c8b461e..f46127b 100644 --- a/app/Http/Controllers/API/v1/CategoryController.php +++ b/app/Http/Controllers/API/v1/CategoryController.php @@ -91,6 +91,7 @@ public function index(Request $request): JsonResponse new OA\Property(property: 'description', description: 'The description of the category', type: 'string'), new OA\Property(property: 'icon', description: 'The icon of the category (file or icon string)', type: 'string'), new OA\Property(property: 'icon_type', description: 'The type of the icon (icon or emoji or image)', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), ] ) ), @@ -133,6 +134,7 @@ public function update(Request $request, int $id): JsonResponse 'description' => 'sometimes|string', 'icon' => 'nullable', 'icon_type' => 'required_with:icon|string|in:icon,image,emoji', + 'updated_at' => ['nullable', new Iso8601DateTime], ]); if ($validator->fails()) { @@ -142,12 +144,18 @@ public function update(Request $request, int $id): JsonResponse $data = $validator->validated(); $user = $request->user(); + if (isset($data['updated_at'])) { + $data['updated_at'] = format_iso8601_to_sql($data['updated_at']); + } + $category = $user->categories()->find($id); if (! $category) { return $this->failure('Category not found', 404); } try { + $this->checkUpdatedAt($category, $data); + DB::transaction(function () use ($data, $request, &$category) { $this->updateModel($category, $data, $request); }); diff --git a/app/Http/Controllers/API/v1/GroupController.php b/app/Http/Controllers/API/v1/GroupController.php index 4a72047..4331fb5 100644 --- a/app/Http/Controllers/API/v1/GroupController.php +++ b/app/Http/Controllers/API/v1/GroupController.php @@ -211,6 +211,7 @@ public function show(int $id): JsonResponse ), new OA\Property(property: 'icon', description: 'The icon of the group (file or icon string)', type: 'string'), new OA\Property(property: 'icon_type', description: 'The type of the icon (icon or emoji or image)', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), ] ) ), @@ -252,6 +253,7 @@ public function update(Request $request, int $id): JsonResponse 'description' => 'sometimes|string|max:255', 'icon' => 'nullable', 'icon_type' => 'required_with:icon|string|in:icon,image,emoji', + 'updated_at' => ['nullable', new Iso8601DateTime], ]); if ($validator->fails()) { @@ -262,10 +264,16 @@ public function update(Request $request, int $id): JsonResponse $group = $user->groups()->find($id); $data = $validator->validated(); + if (isset($data['updated_at'])) { + $data['updated_at'] = format_iso8601_to_sql($data['updated_at']); + } + if (! $group) { return $this->failure('Group not found', 404); } try { + $this->checkUpdatedAt($group, $data); + DB::transaction(function () use ($data, $request, &$group) { $this->updateModel($group, $data, $request); }); diff --git a/app/Http/Controllers/API/v1/PartyController.php b/app/Http/Controllers/API/v1/PartyController.php index 3e1a9df..e198916 100644 --- a/app/Http/Controllers/API/v1/PartyController.php +++ b/app/Http/Controllers/API/v1/PartyController.php @@ -211,6 +211,7 @@ public function show(int $id): JsonResponse new OA\Property(property: 'description', type: 'string', example: 'income from John Doe'), new OA\Property(property: 'icon', description: 'The icon of the party (file or icon string)', type: 'string'), new OA\Property(property: 'icon_type', description: 'The type of the icon (icon or emoji or image)', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), ] ) ), @@ -256,15 +257,22 @@ public function update(Request $request, int $id): JsonResponse 'icon' => 'nullable', 'icon_type' => 'required_with:icon|string|in:icon,image,emoji', 'type' => 'sometimes|string|in:individual,organization,business,partnership,non_profit,government_agency,educational_institution,healthcare_provider', + 'updated_at' => ['nullable', new Iso8601DateTime], ]); $user = $request->user(); $party = $user->parties()->find($id); + if (isset($validatedData['updated_at'])) { + $validatedData['updated_at'] = format_iso8601_to_sql($validatedData['updated_at']); + } + if (! $party) { return $this->failure('Party not found', 404); } try { + $this->checkUpdatedAt($party, $validatedData); + DB::transaction(function () use ($validatedData, $request, &$party) { $this->updateModel($party, $validatedData, $request); }); diff --git a/app/Http/Controllers/API/v1/TransactionController.php b/app/Http/Controllers/API/v1/TransactionController.php index 94c2a04..4583c3b 100644 --- a/app/Http/Controllers/API/v1/TransactionController.php +++ b/app/Http/Controllers/API/v1/TransactionController.php @@ -537,6 +537,8 @@ public function update(Request $request, $id): JsonResponse return $this->failure($e->getMessage(), $e->getStatusCode()); } try { + $this->checkUpdatedAt($transaction, $validatedData); + $transaction = DB::transaction(function () use ($validatedData, $transaction, $recurring_transaction_data, $request) { $transaction->update(array_filter($validatedData, fn ($value) => $value !== null)); diff --git a/app/Http/Controllers/API/v1/WalletController.php b/app/Http/Controllers/API/v1/WalletController.php index 22d7042..4715b51 100644 --- a/app/Http/Controllers/API/v1/WalletController.php +++ b/app/Http/Controllers/API/v1/WalletController.php @@ -213,6 +213,7 @@ public function show(Request $request, int $id): JsonResponse new OA\Property(property: 'description', type: 'string', example: 'Updated wallet description'), new OA\Property(property: 'icon', description: 'The icon of the wallet (file or icon string)', type: 'string'), new OA\Property(property: 'icon_type', description: 'The type of the icon (icon or emoji or image)', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), ] ) ), @@ -260,15 +261,22 @@ public function update(Request $request, int $id): JsonResponse 'balance' => 'sometimes|numeric|decimal:0,4', 'icon' => 'nullable', 'icon_type' => 'required_with:icon|string|in:icon,image,emoji', + 'updated_at' => ['nullable', new Iso8601DateTime], ]); $user = $request->user(); + if (isset($validatedData['updated_at'])) { + $validatedData['updated_at'] = format_iso8601_to_sql($validatedData['updated_at']); + } + $wallet = $user->wallets()->find($id); if (! $wallet) { return $this->failure('Wallet not found', 404); } try { + $this->checkUpdatedAt($wallet, $validatedData); + DB::transaction(function () use ($validatedData, $request, &$wallet) { $this->updateModel($wallet, $validatedData, $request); }); diff --git a/app/Http/Traits/ApiQueryable.php b/app/Http/Traits/ApiQueryable.php index f6cd8f8..13c63f5 100644 --- a/app/Http/Traits/ApiQueryable.php +++ b/app/Http/Traits/ApiQueryable.php @@ -61,4 +61,17 @@ private function updateModel(Model $model, array $validatedData, Request $reques } FileService::updateIcon($model, $validatedData, $request); } + + protected function checkUpdatedAt(Model $model, array &$validatedData): void + { + if (isset($validatedData['updated_at'])) { + // Ensure the updated at is always greater than the created at + $created_at = $model->created_at; + $updated_at = Carbon::parse($validatedData['updated_at']); + if ($updated_at->lt($created_at)) { + // Remove the value so that laravel defaults to now(); + unset($validatedData['updated_at']); + } + } + } } diff --git a/public/docs/api.json b/public/docs/api.json index ea01954..51340fa 100644 --- a/public/docs/api.json +++ b/public/docs/api.json @@ -249,6 +249,10 @@ "icon_type": { "description": "The type of the icon (icon or emoji or image)", "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" } }, "type": "object" @@ -501,6 +505,10 @@ "icon_type": { "description": "The type of the icon (icon or emoji or image)", "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" } }, "type": "object" @@ -945,6 +953,10 @@ "icon_type": { "description": "The type of the icon (icon or emoji or image)", "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" } }, "type": "object" @@ -1740,6 +1752,10 @@ "icon_type": { "description": "The type of the icon (icon or emoji or image)", "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" } }, "type": "object" diff --git a/tests/Feature/SyncableModelsTest.php b/tests/Feature/SyncableModelsTest.php index 5d0957a..3e8e359 100644 --- a/tests/Feature/SyncableModelsTest.php +++ b/tests/Feature/SyncableModelsTest.php @@ -8,6 +8,7 @@ use App\Models\Transaction; use App\Models\Transfer; use App\Models\User; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -75,4 +76,27 @@ public function transfer_creates_a_sync_state_on_creation() $this->assertEquals($transfer->syncState->syncable_type, Transfer::class); $this->assertEquals($transfer->syncState->syncable_id, $transfer->id); } + + /** @test */ + public function use_current_date_as_updated_at_if_the_updated_at_is_less_than_created_at_on_update() + { + $user = User::factory()->create(); + + /** @var Transaction */ + $transaction = Transaction::factory()->create([ + 'user_id' => $user->id, + ]); + + $response = $this->actingAs($user)->putJson('/api/v1/transactions/'.$transaction['id'], [ + 'amount' => 200, + 'updated_at' => '2020-05-01T15:17:54.120Z', + ]); + + $response->assertStatus(200); + $transaction = Transaction::find($transaction['id']); + $parsed_date = Carbon::parse('2020-05-01T15:17:54.120Z'); + + $this->assertTrue($parsed_date->lt($transaction->updated_at)); + + } }