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] 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], +]);