Files
coolify/tests/Feature/StorageAndServiceAuditTest.php
2026-10-27 22:25:43 +01:00

484 lines
21 KiB
PHP

<?php
use App\Actions\Database\StopDatabaseProxy;
use App\Livewire\Project\Database\Sqlite\ConnectApplication;
use App\Livewire\Project\Service\Domains;
use App\Livewire\Project\Service\FileStorage;
use App\Livewire\Project\Service\Index;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ManagedDnsRecord;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneSqlite;
use App\Models\Team;
use App\Models\User;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\ManagedDnsRecordCleanup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
$this->withoutDefer();
config()->set('app.maintenance.store', 'array');
Bus::fake();
// Remote file probes report a missing path; path confinement checks pass.
Process::fake(fn ($process) => Process::result(output: match (true) {
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
str_contains(remoteProcessScript($process), '&& echo OK || echo NOK') => 'NOK',
default => 'OK',
}));
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['id' => 0, 'is_dns_validation_enabled' => false, 'is_api_enabled' => true],
));
$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]);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => Crypt::encryptString('test-key'),
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
'ip' => '203.0.113.10',
]);
$this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker'],
));
$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(),
'name' => 'Audit App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'nginx',
]);
$this->service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n postgres:\n image: postgres:17\n",
]);
$this->serviceApplication = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'web',
'service_id' => $this->service->id,
'image' => 'nginx:alpine',
'fqdn' => 'https://web.example.com',
]);
$this->serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'postgres',
'service_id' => $this->service->id,
'image' => 'postgres:17',
]);
});
function storageServiceAuditEvent(string $event): ?AuditEvent
{
return AuditEvent::query()->where('event', $event)->latest('id')->first();
}
function storageServiceAuditFileVolume(Application $application, array $attributes = []): LocalFileVolume
{
return LocalFileVolume::create([
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/app.conf',
'mount_path' => '/etc/app.conf',
'content' => 'original-file-body',
'is_directory' => false,
'is_based_on_git' => false,
'is_preview_suffix_enabled' => true,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
...$attributes,
]);
}
test('creating storages records storage_created for the parent resource type without file content', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('name', 'data')
->set('mount_path', '/app/data')
->call('submitPersistentVolume')
->assertDispatched('success');
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'super-secret-file-body')
->call('submitFileStorage')
->assertDispatched('success');
Livewire::test(Storage::class, ['resource' => $this->serviceApplication])
->set('host_file_storage_source', '/etc/hosts')
->set('host_file_storage_destination', '/etc/hosts')
->call('submitHostFileStorage')
->assertDispatched('success');
$events = AuditEvent::query()->where('event', 'ui.application.storage_created')->orderBy('id')->get();
expect($events)->toHaveCount(2)
->and($events[0]->team_id)->toBe($this->team->id)
->and($events[0]->resource_uuid)->toBe($this->application->uuid)
->and($events[0]->metadata)->toMatchArray([
'application_uuid' => $this->application->uuid,
'storage_type' => 'persistent',
'storage_name' => $this->application->uuid.'-data',
'mount_path' => '/app/data',
])
->and($events[1]->metadata)->toMatchArray(['storage_type' => 'file', 'mount_path' => '/etc/nginx/nginx.conf']);
$serviceEvent = storageServiceAuditEvent('ui.service.storage_created');
expect($serviceEvent->resource_uuid)->toBe($this->service->uuid)
->and($serviceEvent->metadata)->toMatchArray([
'service_application_uuid' => $this->serviceApplication->uuid,
'storage_type' => 'host_file',
'host_path' => '/etc/hosts',
]);
expect(AuditEvent::query()->get()->toJson())->not->toContain('super-secret-file-body');
});
test('creating a database volume records database storage_created', function () {
$database = StandalonePostgresql::create([
'name' => 'audit-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Livewire::test(Storage::class, ['resource' => $database])
->set('name', 'extra')
->set('mount_path', '/extra')
->call('submitPersistentVolume')
->assertDispatched('success');
expect(storageServiceAuditEvent('ui.database.storage_created')->metadata)->toMatchArray([
'database_uuid' => $database->uuid,
'storage_type' => 'persistent',
'mount_path' => '/extra',
]);
});
test('updating file content records changed field names without the content', function () {
$file = storageServiceAuditFileVolume($this->application);
Livewire::test(FileStorage::class, ['fileStorage' => $file])
->set('content', 'new-secret-file-body')
->call('submit')
->assertDispatched('success');
$event = storageServiceAuditEvent('ui.application.storage_updated');
expect($event->metadata)->toMatchArray([
'storage_uuid' => $file->uuid,
'storage_type' => 'file',
'changed_fields' => ['content'],
]);
expect(AuditEvent::query()->get()->toJson())
->not->toContain('new-secret-file-body')
->not->toContain('original-file-body');
});
test('saving a file storage without changes records no audit event', function () {
$file = storageServiceAuditFileVolume($this->application);
Livewire::test(FileStorage::class, ['fileStorage' => $file])
->call('instantSave')
->assertDispatched('success');
expect(AuditEvent::query()->where('event', 'like', '%.storage_updated')->exists())->toBeFalse();
});
test('converting and deleting a file storage records storage events', function () {
$file = storageServiceAuditFileVolume($this->application);
$component = Livewire::test(FileStorage::class, ['fileStorage' => $file])
->call('convertToDirectory');
expect(storageServiceAuditEvent('ui.application.storage_updated')->metadata)->toMatchArray([
'operation' => 'converted_to_directory',
'storage_type' => 'directory',
])->and(storageServiceAuditEvent('ui.application.storage_updated')->metadata['changed_fields'])->toContain('is_directory');
$component->call('convertToFile');
expect(storageServiceAuditEvent('ui.application.storage_updated')->metadata)->toMatchArray([
'operation' => 'converted_to_file',
'storage_type' => 'file',
]);
Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()])
->call('delete', 'password', ['permanently_delete'])
->assertDispatched('success');
expect($file->fresh())->toBeNull()
->and(storageServiceAuditEvent('ui.application.storage_deleted')->metadata)->toMatchArray([
'storage_uuid' => $file->uuid,
'mount_path' => '/etc/app.conf',
'deleted_from_server' => true,
]);
});
test('updating and deleting a persistent volume records storage events and skips unchanged saves', function () {
$volume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-data',
'mount_path' => '/app/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$component = Livewire::test(All::class, ['resource' => $this->application])
->call('submit', $volume->id);
expect(AuditEvent::query()->where('event', 'ui.application.storage_updated')->exists())->toBeFalse();
$component->set("forms.{$volume->id}.mountPath", '/app/other')
->call('submit', $volume->id)
->assertDispatched('success');
expect(storageServiceAuditEvent('ui.application.storage_updated')->metadata)->toMatchArray([
'storage_id' => $volume->id,
'mount_path' => '/app/other',
'changed_fields' => ['mount_path'],
]);
$component->call('delete', $volume->id, 'password');
expect($volume->fresh())->toBeNull()
->and(storageServiceAuditEvent('ui.application.storage_deleted')->metadata)->toMatchArray([
'storage_id' => $volume->id,
'storage_type' => 'persistent',
'docker_volume_deleted' => false,
]);
});
test('connecting and unlinking a sqlite volume records application storage events', function () {
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$sqlite = StandaloneSqlite::create([
'name' => 'app-sqlite',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Livewire::test(ConnectApplication::class, ['database' => $sqlite])
->set('applicationUuid', $this->application->uuid)
->set('mountPath', '/app/database')
->call('connect')
->assertHasNoErrors();
$volume = $this->application->persistentStorages()->sole();
expect(storageServiceAuditEvent('ui.application.storage_created')->metadata)->toMatchArray([
'application_uuid' => $this->application->uuid,
'standalone_sqlite_uuid' => $sqlite->uuid,
'storage_id' => $volume->id,
'mount_path' => '/app/database',
]);
Livewire::test(ConnectApplication::class, ['database' => $sqlite])
->call('unlink', $volume->id);
expect(storageServiceAuditEvent('ui.application.storage_deleted')->metadata)->toMatchArray([
'standalone_sqlite_uuid' => $sqlite->uuid,
'storage_id' => $volume->id,
]);
});
test('updating a service database records changed fields and skips unchanged saves', function () {
$component = Livewire::test(Index::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true])
->call('submitDatabase');
expect(AuditEvent::query()->where('event', 'ui.service_database.updated')->exists())->toBeFalse();
$component->set('humanName', 'Primary DB')
->call('submitDatabase')
->assertDispatched('success', 'Database saved.');
$event = storageServiceAuditEvent('ui.service_database.updated');
expect($event->resource_uuid)->toBe($this->serviceDatabase->uuid)
->and($event->team_id)->toBe($this->team->id)
->and($event->metadata)->toMatchArray([
'service_uuid' => $this->service->uuid,
'changed_fields' => ['human_name'],
]);
});
test('disabling public access of a service database records is_public and public_port', function () {
StopDatabaseProxy::shouldRun()->once();
$this->serviceDatabase->update(['is_public' => true, 'public_port' => 15432]);
Livewire::test(Index::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true])
->call('disablePublicAccess')
->assertDispatched('success');
expect(storageServiceAuditEvent('ui.service_database.updated')->metadata)->toMatchArray([
'service_database_uuid' => $this->serviceDatabase->uuid,
'changed_fields' => ['is_public'],
'is_public' => false,
'public_port' => 15432,
]);
});
test('updating, converting and deleting service applications records service application events', function () {
$component = Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh(), 'embedded' => true])
->set('isGzipEnabled', false)
->call('instantSaveApplicationSettings')
->assertDispatched('success');
expect(storageServiceAuditEvent('ui.service_application.updated')->metadata)->toMatchArray([
'service_application_uuid' => $this->serviceApplication->uuid,
])->and(storageServiceAuditEvent('ui.service_application.updated')->metadata['changed_fields'])->toContain('is_gzip_enabled');
$component->set('maxRestartCount', 7)->call('saveMaxRestartCount');
expect(storageServiceAuditEvent('ui.service_application.updated')->metadata['changed_fields'])->toContain('max_restart_count');
$component->call('convertToDatabase');
$convertedDatabase = $this->service->databases()->where('name', 'web')->sole();
expect(storageServiceAuditEvent('ui.service_application.converted_to_database')->metadata)->toMatchArray([
'service_application_uuid' => $this->serviceApplication->uuid,
'service_database_uuid' => $convertedDatabase->uuid,
]);
Livewire::test(Index::class, ['serviceApplication' => $convertedDatabase, 'embedded' => true])
->call('convertToApplication');
$convertedApplication = $this->service->applications()->where('name', 'web')->sole();
expect(storageServiceAuditEvent('ui.service_database.converted_to_application')->metadata)->toMatchArray([
'service_database_uuid' => $convertedDatabase->uuid,
'service_application_uuid' => $convertedApplication->uuid,
]);
Livewire::test(Index::class, ['serviceApplication' => $convertedApplication, 'embedded' => true])
->call('deleteApplication', 'password');
Livewire::test(Index::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true])
->call('deleteDatabase', 'password');
expect(storageServiceAuditEvent('ui.service_application.deleted')->resource_uuid)->toBe($convertedApplication->uuid)
->and(storageServiceAuditEvent('ui.service_database.deleted')->resource_uuid)->toBe($this->serviceDatabase->uuid);
});
test('saving service application domains records service application updates', function () {
Queue::fake();
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('updateForceHttps', $this->serviceApplication->id, false)
->assertHasNoErrors();
expect(storageServiceAuditEvent('ui.service_application.updated')->metadata)->toMatchArray([
'service_uuid' => $this->service->uuid,
'service_application_uuid' => $this->serviceApplication->uuid,
'changed_fields' => ['is_force_https_enabled'],
]);
$component->set('newServiceApplicationId', $this->serviceApplication->id)
->set('newDomain', 'https://second.example.com')
->call('addDomain')
->assertHasNoErrors();
expect($this->serviceApplication->fresh()->fqdn)->toContain('https://second.example.com')
->and(storageServiceAuditEvent('ui.service_application.updated')->metadata['changed_fields'])->toContain('fqdn');
});
test('the service application API update records changed fields', function () {
auth()->forgetGuards();
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'audit-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->withToken($token->getKey().'|'.$plainTextToken)
->patchJson("/api/v1/services/{$this->service->uuid}/applications/{$this->serviceApplication->uuid}", [
'human_name' => 'Web UI',
])
->assertOk();
$event = storageServiceAuditEvent('api.service_application.updated');
expect($event->team_id)->toBe($this->team->id)
->and($event->metadata)->toMatchArray([
'service_uuid' => $this->service->uuid,
'service_application_uuid' => $this->serviceApplication->uuid,
'changed_fields' => ['human_name'],
]);
});
test('forgetting a managed dns record without touching the provider is audited', function () {
$integrationToken = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
$zone = DnsProviderZone::factory()->for($integrationToken)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
$notOwned = ManagedDnsRecord::factory()->for($zone, 'zone')->create(['name' => 'hand.example.com']);
$kept = ManagedDnsRecord::factory()->owned()->for($zone, 'zone')->create(['name' => 'kept.example.com']);
Http::fake();
$cleanup = app(ManagedDnsRecordCleanup::class);
$cleanup->release($notOwned, [], true);
$cleanup->release($kept, [], false);
$events = AuditEvent::query()->where('event', 'ui.dns_record.delete_skipped')->orderBy('id')->get();
expect($notOwned->fresh())->toBeNull()
->and($kept->fresh())->toBeNull()
->and($events->map(fn (AuditEvent $event) => [$event->metadata['hostname'], $event->metadata['reason']])->all())->toBe([
['hand.example.com', 'not_owned'],
['kept.example.com', 'kept_by_user'],
]);
Http::assertNothingSent();
});
test('forgetting an owned dns record that is already gone at the provider is audited', function () {
$integrationToken = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
$zone = DnsProviderZone::factory()->for($integrationToken)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
$record = ManagedDnsRecord::factory()->owned()->for($zone, 'zone')->create(['name' => 'gone.example.com']);
Http::fake(['api.cloudflare.com/*' => Http::response(['success' => false], 404)]);
app(CloudflareDnsProvider::class)->deleteRecord($record);
expect($record->fresh())->toBeNull()
->and(storageServiceAuditEvent('ui.dns_record.already_deleted')->metadata)->toMatchArray([
'hostname' => 'gone.example.com',
'provider' => 'cloudflare',
'zone' => 'example.com',
]);
});