From e67b27040ff2a157780bb4a75ed3c77a68aa9b4e Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 26 Aug 2026 13:16:13 +0000 Subject: [PATCH] test(database): cover Heading::flush workflow; document sudo-parser limitation Addresses CodeRabbit review on #11513: - Add Livewire feature tests for Heading::flush() covering success + audit event, non-cache guard, denied manage permission, and menu visibility for cache vs non-cache databases. - Document the pre-existing parseCommandsByLineForSudo() limitation that mutates passwords containing shell sequences on non-root servers. --- app/Actions/Database/FlushCacheDatabase.php | 6 + tests/Feature/DatabaseFlushCacheTest.php | 126 ++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/Feature/DatabaseFlushCacheTest.php diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php index 998d2cd5c8..f5c8254978 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -34,6 +34,12 @@ class FlushCacheDatabase * The command connects over the container's local plaintext port (available even when * SSL is enabled, because Coolify never disables it), so no TLS flags are needed. The * password is shell-escaped so it cannot break out of the argument and inject commands. + * + * Note: on non-root servers the command passes through parseCommandsByLineForSudo(), + * which rewrites `$(`, ` | `, `&&` and `||` line-wide, so a password containing those + * sequences is mutated even inside the escaped quotes and the flush fails to authenticate. + * This is a limitation shared with every other password-carrying remote command in the + * codebase (e.g. DatabaseBackupJob); the escaping still holds, so no command is injected. */ public function buildFlushCommand(string $containerName, ?string $password): string { diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php new file mode 100644 index 0000000000..a0a053a067 --- /dev/null +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -0,0 +1,126 @@ +withoutDefer(); + + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +function makeRedis(mixed $environment, mixed $destination): StandaloneRedis +{ + return StandaloneRedis::create([ + 'name' => 'cache-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'redis_username' => 'default', + 'status' => 'running:healthy', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +test('flushes the cache of a running redis database and records an audit event', function () { + $redis = makeRedis($this->environment, $this->destination); + + FlushCacheDatabase::mock()->shouldReceive('handle')->once(); + + Livewire::test(Heading::class, ['database' => $redis]) + ->call('flush') + ->assertDispatched('success'); + + expect(AuditEvent::query() + ->where('event', 'ui.database.flushed') + ->where('resource_uuid', $redis->uuid) + ->exists())->toBeTrue(); +}); + +test('refuses to flush a non-cache database and does not run the action', function () { + $postgres = StandalonePostgresql::create([ + 'name' => 'app-postgres', + 'image' => 'postgres:16', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + FlushCacheDatabase::mock()->shouldReceive('handle')->never(); + + Livewire::test(Heading::class, ['database' => $postgres]) + ->call('flush') + ->assertDispatched('error'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +test('denies flushing to a member without manage permission', function () { + $redis = makeRedis($this->environment, $this->destination); + + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + FlushCacheDatabase::mock()->shouldReceive('handle')->never(); + + Livewire::test(Heading::class, ['database' => $redis]) + ->call('flush') + ->assertDispatched('error'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +test('only cache databases expose the flush cache action in the heading', function () { + $redis = makeRedis($this->environment, $this->destination); + + Livewire::test(Heading::class, ['database' => $redis]) + ->assertSee('Flush cache'); + + $postgres = StandalonePostgresql::create([ + 'name' => 'app-postgres', + 'image' => 'postgres:16', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + Livewire::test(Heading::class, ['database' => $postgres]) + ->assertDontSee('Flush cache'); +});