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')
- Permanently erase every key in
- {{ $database->name }}
- by running FLUSHALL ASYNC inside the running container.
-
+ Permanently remove every key from + {{ $resourceName }}. + The database keeps running. +
+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], +]);