From 08a12c392a2e205bda411069950ec4a3ef304ef3 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sat, 2 May 2026 23:11:22 +1000 Subject: [PATCH 1/4] fix(deploy): cast force param as boolean to prevent cache bust on every deploy $request->input('force') ?? false reads "false" as a non-empty string, which PHP coerces to true. Replace with $request->boolean() which uses filter_var(FILTER_VALIDATE_BOOLEAN), correctly mapping "false" -> false. --- app/Http/Controllers/Api/DeployController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c93731d68..4036c5dd0 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -366,7 +366,7 @@ public function deploy(Request $request) $uuids = $request->input('uuid'); $tags = $request->input('tag'); - $force = $request->input('force') ?? false; + $force = $request->boolean('force'); $pullRequestId = $request->input('pull_request_id', $request->input('pr')); $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0; $dockerTag = $request->string('docker_tag')->trim()->value() ?: null; From 7c3723d20756c6f6f2de0da286c6d607fc672674 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sat, 2 May 2026 23:26:22 +1000 Subject: [PATCH 2/4] test(deploy): assert force=false query param does not set force_rebuild Regression test for PHP string truthy coercion bug: - force=false as query string should not trigger --no-cache - force=true as query string should trigger --no-cache - missing force param defaults to false --- tests/Feature/DeployForceBooleanCastTest.php | 78 ++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/Feature/DeployForceBooleanCastTest.php diff --git a/tests/Feature/DeployForceBooleanCastTest.php b/tests/Feature/DeployForceBooleanCastTest.php new file mode 100644 index 000000000..d00136ed2 --- /dev/null +++ b/tests/Feature/DeployForceBooleanCastTest.php @@ -0,0 +1,78 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'coolify-'.Str::lower(Str::random(8)), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + ]); +}); + +// Regression test: $request->input('force') ?? false coerced the string "false" to bool true, +// causing every API deploy with force=false to run with --no-cache. +test('force=false query string param does not set force_rebuild', function () { + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=false'); + + $response->assertSuccessful(); + + $deployment = $this->application->deployment_queue()->latest('id')->first(); + expect($deployment)->not()->toBeNull(); + expect($deployment->force_rebuild)->toBeFalse(); +}); + +test('force=true query string param sets force_rebuild', function () { + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=true'); + + $response->assertSuccessful(); + + $deployment = $this->application->deployment_queue()->latest('id')->first(); + expect($deployment)->not()->toBeNull(); + expect($deployment->force_rebuild)->toBeTrue(); +}); + +test('omitting force param does not set force_rebuild', function () { + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid); + + $response->assertSuccessful(); + + $deployment = $this->application->deployment_queue()->latest('id')->first(); + expect($deployment)->not()->toBeNull(); + expect($deployment->force_rebuild)->toBeFalse(); +}); From 6eb527d0ab818ffc4a960d90c93de8521258bc99 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sat, 2 May 2026 23:37:57 +1000 Subject: [PATCH 3/4] test(deploy): expand force param tests to cover all coercion cases Adds datasets for falsy (false, 0) and truthy (true, 1) query string values, covering the full coercion table from the PR description. --- tests/Feature/DeployForceBooleanCastTest.php | 33 +++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/Feature/DeployForceBooleanCastTest.php b/tests/Feature/DeployForceBooleanCastTest.php index d00136ed2..5dc08d29b 100644 --- a/tests/Feature/DeployForceBooleanCastTest.php +++ b/tests/Feature/DeployForceBooleanCastTest.php @@ -43,28 +43,47 @@ }); // Regression test: $request->input('force') ?? false coerced the string "false" to bool true, -// causing every API deploy with force=false to run with --no-cache. -test('force=false query string param does not set force_rebuild', function () { +// causing every API deploy with ?force=false to run with --no-cache and bypass layer caching. +// +// Input | input('force') ?? false (old) | boolean('force') (fix) +// ------------|-------------------------------|---------------------- +// "false" | true (bug — non-empty string)| false (correct) +// "true" | true | true +// "0" | false (PHP special-cases "0") | false +// "1" | true | true +// absent/null | false | false + +dataset('falsy force values', [ + 'string false' => ['false'], + 'string 0' => ['0'], +]); + +dataset('truthy force values', [ + 'string true' => ['true'], + 'string 1' => ['1'], +]); + +test('force= query string param does not set force_rebuild', function (string $value) { $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=false'); + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); $response->assertSuccessful(); $deployment = $this->application->deployment_queue()->latest('id')->first(); expect($deployment)->not()->toBeNull(); expect($deployment->force_rebuild)->toBeFalse(); -}); +})->with('falsy force values'); -test('force=true query string param sets force_rebuild', function () { +test('force= query string param sets force_rebuild', function (string $value) { $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=true'); + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); $response->assertSuccessful(); $deployment = $this->application->deployment_queue()->latest('id')->first(); expect($deployment)->not()->toBeNull(); expect($deployment->force_rebuild)->toBeTrue(); -}); +})->with('truthy force values'); test('omitting force param does not set force_rebuild', function () { $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) From 3b763d54cccc45fbe74370d3074814a93f5f3633 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sun, 3 May 2026 00:03:14 +1000 Subject: [PATCH 4/4] test(deploy): remove coercion test to keep PR focused on the fix --- tests/Feature/DeployForceBooleanCastTest.php | 97 -------------------- 1 file changed, 97 deletions(-) delete mode 100644 tests/Feature/DeployForceBooleanCastTest.php diff --git a/tests/Feature/DeployForceBooleanCastTest.php b/tests/Feature/DeployForceBooleanCastTest.php deleted file mode 100644 index 5dc08d29b..000000000 --- a/tests/Feature/DeployForceBooleanCastTest.php +++ /dev/null @@ -1,97 +0,0 @@ -team = Team::factory()->create(); - $this->user = User::factory()->create(); - $this->team->members()->attach($this->user->id, ['role' => 'owner']); - - $plainTextToken = Str::random(40); - $token = $this->user->tokens()->create([ - 'name' => 'test-token', - 'token' => hash('sha256', $plainTextToken), - 'abilities' => ['*'], - 'team_id' => $this->team->id, - ]); - $this->bearerToken = $token->getKey().'|'.$plainTextToken; - - $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create([ - 'server_id' => $this->server->id, - 'network' => 'coolify-'.Str::lower(Str::random(8)), - ]); - $this->project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); - - $this->application = Application::factory()->create([ - 'uuid' => (string) Str::uuid(), - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => StandaloneDocker::class, - ]); -}); - -// Regression test: $request->input('force') ?? false coerced the string "false" to bool true, -// causing every API deploy with ?force=false to run with --no-cache and bypass layer caching. -// -// Input | input('force') ?? false (old) | boolean('force') (fix) -// ------------|-------------------------------|---------------------- -// "false" | true (bug — non-empty string)| false (correct) -// "true" | true | true -// "0" | false (PHP special-cases "0") | false -// "1" | true | true -// absent/null | false | false - -dataset('falsy force values', [ - 'string false' => ['false'], - 'string 0' => ['0'], -]); - -dataset('truthy force values', [ - 'string true' => ['true'], - 'string 1' => ['1'], -]); - -test('force= query string param does not set force_rebuild', function (string $value) { - $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); - - $response->assertSuccessful(); - - $deployment = $this->application->deployment_queue()->latest('id')->first(); - expect($deployment)->not()->toBeNull(); - expect($deployment->force_rebuild)->toBeFalse(); -})->with('falsy force values'); - -test('force= query string param sets force_rebuild', function (string $value) { - $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); - - $response->assertSuccessful(); - - $deployment = $this->application->deployment_queue()->latest('id')->first(); - expect($deployment)->not()->toBeNull(); - expect($deployment->force_rebuild)->toBeTrue(); -})->with('truthy force values'); - -test('omitting force param does not set force_rebuild', function () { - $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid); - - $response->assertSuccessful(); - - $deployment = $this->application->deployment_queue()->latest('id')->first(); - expect($deployment)->not()->toBeNull(); - expect($deployment->force_rebuild)->toBeFalse(); -});