mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 10:04:36 +08:00
402 lines
17 KiB
PHP
402 lines
17 KiB
PHP
<?php
|
|
|
|
use App\Actions\Proxy\SaveProxyConfiguration;
|
|
use App\Actions\Server\StartSentinel;
|
|
use App\Helpers\SslHelper;
|
|
use App\Jobs\ValidateNewCloudServerJob;
|
|
use App\Livewire\Destination\New\Docker as NewDockerDestination;
|
|
use App\Livewire\Destination\Show as DestinationShow;
|
|
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
|
|
use App\Livewire\Server\Advanced;
|
|
use App\Livewire\Server\CaCertificate\Show as CaCertificateShow;
|
|
use App\Livewire\Server\Charts;
|
|
use App\Livewire\Server\CloudflareTunnel;
|
|
use App\Livewire\Server\Destinations;
|
|
use App\Livewire\Server\DockerCleanup;
|
|
use App\Livewire\Server\GithubRunners;
|
|
use App\Livewire\Server\New\ByVultr;
|
|
use App\Livewire\Server\Proxy;
|
|
use App\Livewire\Server\Security\TerminalAccess;
|
|
use App\Livewire\Server\Sentinel;
|
|
use App\Livewire\Server\Show as ServerShow;
|
|
use App\Livewire\Server\Swarm;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\CloudInitScript;
|
|
use App\Models\CloudProviderToken;
|
|
use App\Models\Environment;
|
|
use App\Models\GithubApp;
|
|
use App\Models\GithubRunnerConfig;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\SwarmDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Once;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Bus::fake([ValidateNewCloudServerJob::class]);
|
|
|
|
$this->withoutDefer();
|
|
Server::flushIdentityMap();
|
|
Queue::fake();
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
Once::flush();
|
|
|
|
$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]);
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Server::flushIdentityMap();
|
|
});
|
|
|
|
/**
|
|
* @return Collection<int, AuditEvent>
|
|
*/
|
|
function serverSettingsAuditRows(string $event): Collection
|
|
{
|
|
return AuditEvent::query()->where('event', $event)->get();
|
|
}
|
|
|
|
function serverSettingsAuditVultrPrivateKey(): string
|
|
{
|
|
return <<<'KEY'
|
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
|
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
|
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
|
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
|
-----END OPENSSH PRIVATE KEY-----
|
|
KEY;
|
|
}
|
|
|
|
function serverSettingsAuditGithubApp(Team $team): GithubApp
|
|
{
|
|
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
|
openssl_pkey_export($rsaKey, $pemKey);
|
|
$privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $team->id]);
|
|
|
|
// Missing runner permissions, so saving does not call the GitHub API.
|
|
return GithubApp::create([
|
|
'name' => 'audit-runner-app',
|
|
'organization' => 'acme',
|
|
'api_url' => 'https://api.github.com',
|
|
'html_url' => 'https://github.com',
|
|
'custom_user' => 'git',
|
|
'custom_port' => 22,
|
|
'app_id' => 4444,
|
|
'installation_id' => 4445,
|
|
'webhook_secret' => 'secret',
|
|
'private_key_id' => $privateKey->id,
|
|
'team_id' => $team->id,
|
|
'is_system_wide' => false,
|
|
'organization_self_hosted_runners' => null,
|
|
'actions' => null,
|
|
'webhook_events' => ['push'],
|
|
]);
|
|
}
|
|
|
|
test('toggling terminal access records enabled and disabled events', function () {
|
|
$this->server->settings->update(['is_terminal_enabled' => false]);
|
|
|
|
$component = Livewire::test(TerminalAccess::class, ['server_uuid' => $this->server->uuid]);
|
|
$component->call('toggleTerminal', 'password');
|
|
$component->call('toggleTerminal', 'password');
|
|
|
|
$enabled = serverSettingsAuditRows('ui.server.terminal_access.enabled');
|
|
expect($enabled)->toHaveCount(1)
|
|
->and($enabled->first()->team_id)->toBe($this->team->id)
|
|
->and($enabled->first()->metadata['server_uuid'])->toBe($this->server->uuid)
|
|
->and(serverSettingsAuditRows('ui.server.terminal_access.disabled'))->toHaveCount(1);
|
|
});
|
|
|
|
test('server settings saves record changed setting names without the sentinel token', function () {
|
|
$newToken = 'audit-secret-sentinel-token-123';
|
|
|
|
Livewire::test(ServerShow::class, ['server_uuid' => $this->server->uuid])
|
|
->set('wildcardDomain', 'https://apps.example.com')
|
|
->set('sentinelToken', $newToken)
|
|
->call('submit')
|
|
->assertHasNoErrors();
|
|
|
|
$rows = serverSettingsAuditRows('ui.server.settings_updated');
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->team_id)->toBe($this->team->id)
|
|
->and($rows->first()->metadata['changed_fields'])->toEqualCanonicalizing(['wildcard_domain', 'sentinel_token'])
|
|
->and(AuditEvent::query()->where('metadata', 'like', "%{$newToken}%")->exists())->toBeFalse();
|
|
});
|
|
|
|
test('saving server settings without changes records no event', function () {
|
|
Livewire::test(ServerShow::class, ['server_uuid' => $this->server->uuid])
|
|
->call('submit')
|
|
->assertHasNoErrors();
|
|
|
|
expect(serverSettingsAuditRows('ui.server.settings_updated'))->toBeEmpty();
|
|
});
|
|
|
|
test('cloudflare tunnel enable, disable and configuration record events without the tunnel token', function () {
|
|
$component = Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]);
|
|
|
|
$component->call('manualCloudflareConfig');
|
|
expect(serverSettingsAuditRows('ui.server.cloudflare_tunnel.enabled'))->toHaveCount(1);
|
|
|
|
$component->call('toggleCloudflareTunnels');
|
|
expect(serverSettingsAuditRows('ui.server.cloudflare_tunnel.disabled'))->toHaveCount(1)
|
|
->and($this->server->settings->fresh()->is_cloudflare_tunnel)->toBeFalsy();
|
|
|
|
$component->set('cloudflare_token', 'cf-audit-secret-token')
|
|
->set('ssh_domain', 'ssh.example.com')
|
|
->call('automatedCloudflareConfig');
|
|
|
|
$configured = serverSettingsAuditRows('ui.server.cloudflare_tunnel.configuration_started');
|
|
expect($configured)->toHaveCount(1)
|
|
->and($configured->first()->metadata['ssh_domain'])->toBe('ssh.example.com')
|
|
->and(AuditEvent::query()->where('metadata', 'like', '%cf-audit-secret-token%')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('docker cleanup settings record changed fields once and skip unchanged saves', function () {
|
|
$component = Livewire::test(DockerCleanup::class, ['server_uuid' => $this->server->uuid])
|
|
->set('deleteUnusedVolumes', true)
|
|
->call('instantSave');
|
|
|
|
$component->call('instantSave');
|
|
|
|
$rows = serverSettingsAuditRows('ui.server.docker_cleanup.updated');
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->metadata['changed_fields'])->toBe(['delete_unused_volumes']);
|
|
});
|
|
|
|
test('sentinel instant save records the changed settings', function () {
|
|
Livewire::test(Sentinel::class, ['server' => $this->server])
|
|
->set('isSentinelDebugEnabled', true)
|
|
->call('instantSave');
|
|
|
|
$rows = serverSettingsAuditRows('ui.server.sentinel.updated');
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->metadata['changed_fields'])->toBe(['is_sentinel_debug_enabled']);
|
|
});
|
|
|
|
test('metrics settings and the metrics toggle record sentinel updates', function () {
|
|
StartSentinel::shouldRun();
|
|
|
|
Livewire::test(Charts::class, ['server_uuid' => $this->server->uuid])
|
|
->set('sentinelMetricsHistoryDays', 30)
|
|
->call('saveMetricsSettings')
|
|
->call('toggleMetrics');
|
|
|
|
$changedFields = serverSettingsAuditRows('ui.server.sentinel.updated')->pluck('metadata.changed_fields')->all();
|
|
expect($changedFields)->toBe([['sentinel_metrics_history_days'], ['is_metrics_enabled']]);
|
|
});
|
|
|
|
test('advanced and swarm settings record server settings updates', function () {
|
|
Livewire::test(Advanced::class, ['server_uuid' => $this->server->uuid])
|
|
->set('concurrentBuilds', 4)
|
|
->call('instantSave');
|
|
|
|
$swarmServer = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$swarmServer->settings()->update(['is_swarm_manager' => true]);
|
|
|
|
Livewire::test(Swarm::class, ['server_uuid' => $this->server->uuid])
|
|
->set('isSwarmWorker', true)
|
|
->call('instantSave');
|
|
|
|
$changedFields = serverSettingsAuditRows('ui.server.settings_updated')->pluck('metadata.changed_fields')->all();
|
|
expect($changedFields)->toBe([['concurrent_builds'], ['is_swarm_worker']]);
|
|
});
|
|
|
|
test('proxy settings and configuration saves record proxy events', function () {
|
|
SaveProxyConfiguration::shouldRun();
|
|
|
|
Livewire::test(Proxy::class, ['server' => $this->server])
|
|
->set('generateExactLabels', true)
|
|
->call('instantSave')
|
|
->set('proxySettings', "services:\n traefik:\n image: traefik:v3.6\n")
|
|
->call('submit');
|
|
|
|
$updated = serverSettingsAuditRows('ui.server.proxy.updated');
|
|
expect($updated)->toHaveCount(1)
|
|
->and($updated->first()->metadata['changed_fields'])->toBe(['generate_exact_labels'])
|
|
->and(serverSettingsAuditRows('ui.server.proxy.configuration_saved'))->toHaveCount(1);
|
|
});
|
|
|
|
test('github runner settings record created, updated and disabled events', function () {
|
|
$this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]);
|
|
Server::flushIdentityMap();
|
|
$githubApp = serverSettingsAuditGithubApp($this->team);
|
|
|
|
$component = Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $githubApp->id)
|
|
->call('submit')
|
|
->assertHasNoErrors();
|
|
|
|
$component->set('maxRunners', 5)->call('submit');
|
|
$component->call('submit');
|
|
|
|
GithubRunnerConfig::query()->update(['is_enabled' => true]);
|
|
$component->call('toggleEnabled');
|
|
|
|
$created = serverSettingsAuditRows('ui.server.github_runners.created');
|
|
$updated = serverSettingsAuditRows('ui.server.github_runners.updated');
|
|
expect($created)->toHaveCount(1)
|
|
->and($created->first()->metadata['github_app_uuid'])->toBe($githubApp->uuid)
|
|
->and($updated)->toHaveCount(1)
|
|
->and($updated->first()->metadata['changed_fields'])->toBe(['max_runners'])
|
|
->and(serverSettingsAuditRows('ui.server.github_runners.disabled'))->toHaveCount(1);
|
|
});
|
|
|
|
test('CA certificate save and regeneration record events without certificate contents', function () {
|
|
Livewire::test(CaCertificateShow::class, ['server_uuid' => $this->server->uuid])
|
|
->call('regenerateCaCertificate')
|
|
->assertDispatched('success');
|
|
|
|
$caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->firstOrFail();
|
|
|
|
Livewire::test(CaCertificateShow::class, ['server_uuid' => $this->server->uuid])
|
|
->set('certificateContent', $caCertificate->ssl_certificate)
|
|
->call('saveCaCertificate')
|
|
->assertDispatched('success');
|
|
|
|
expect(serverSettingsAuditRows('ui.server.ca_certificate.regenerated'))->toHaveCount(1)
|
|
->and(serverSettingsAuditRows('ui.server.ca_certificate.updated'))->toHaveCount(1)
|
|
->and(AuditEvent::query()->where('metadata', 'like', '%PRIVATE KEY%')->exists())->toBeFalse()
|
|
->and(AuditEvent::query()->where('metadata', 'like', '%BEGIN CERTIFICATE%')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('regenerating a database SSL certificate records an event', function () {
|
|
$this->server->generateCaCertificate();
|
|
$caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->firstOrFail();
|
|
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
|
|
$database = StandalonePostgresql::create([
|
|
'name' => 'audit-ssl-db',
|
|
'status' => 'exited:unhealthy',
|
|
'enable_ssl' => true,
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'postgres',
|
|
'image' => 'postgres:16-alpine',
|
|
]);
|
|
|
|
SslHelper::generateSslCertificate(
|
|
commonName: $database->uuid,
|
|
resourceType: $database->getMorphClass(),
|
|
resourceId: $database->id,
|
|
serverId: $this->server->id,
|
|
caCert: $caCertificate->ssl_certificate,
|
|
caKey: $caCertificate->ssl_private_key,
|
|
configurationDir: '/data/coolify/databases/'.$database->uuid,
|
|
mountPath: '/var/lib/postgresql/certs',
|
|
);
|
|
|
|
Livewire::test(PostgresqlStatusInfo::class, ['database' => $database])
|
|
->call('regenerateSslCertificate')
|
|
->assertDispatched('success');
|
|
|
|
$rows = serverSettingsAuditRows('ui.database.ssl_certificate_regenerated');
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->team_id)->toBe($this->team->id)
|
|
->and($rows->first()->metadata['database_uuid'])->toBe($database->uuid)
|
|
->and(AuditEvent::query()->where('metadata', 'like', '%PRIVATE KEY%')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('destination create, rename and delete record destination events', function () {
|
|
Livewire::test(NewDockerDestination::class, ['server_id' => (string) $this->server->id])
|
|
->set('network', 'audit-network')
|
|
->call('submit');
|
|
|
|
Livewire::test(Destinations::class, ['server_uuid' => $this->server->uuid])
|
|
->call('add', 'scanned-network');
|
|
|
|
$created = serverSettingsAuditRows('ui.destination.created');
|
|
expect($created)->toHaveCount(2)
|
|
->and($created->pluck('metadata.destination_type')->unique()->all())->toBe(['standalone'])
|
|
->and($created->first()->team_id)->toBe($this->team->id);
|
|
|
|
$swarmDestination = SwarmDocker::create([
|
|
'name' => 'audit-swarm',
|
|
'network' => 'audit-swarm-network',
|
|
'server_id' => $this->server->id,
|
|
]);
|
|
|
|
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $swarmDestination->uuid])
|
|
->call('submit');
|
|
expect(serverSettingsAuditRows('ui.destination.updated'))->toBeEmpty();
|
|
|
|
$component->set('name', 'audit-swarm-renamed')->call('submit');
|
|
$component->call('delete');
|
|
|
|
$updated = serverSettingsAuditRows('ui.destination.updated');
|
|
$deleted = serverSettingsAuditRows('ui.destination.deleted');
|
|
expect($updated)->toHaveCount(1)
|
|
->and($updated->first()->metadata['changed_fields'])->toBe(['name'])
|
|
->and($deleted)->toHaveCount(1)
|
|
->and($deleted->first()->metadata['destination_uuid'])->toBe($swarmDestination->uuid)
|
|
->and($deleted->first()->metadata['destination_type'])->toBe('swarm');
|
|
});
|
|
|
|
test('saving a cloud-init script from the server wizard records an event without the script', function () {
|
|
$token = CloudProviderToken::create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'vultr',
|
|
'token' => 'test-vultr-token',
|
|
'name' => 'Test Vultr Token',
|
|
]);
|
|
$privateKey = PrivateKey::create([
|
|
'team_id' => $this->team->id,
|
|
'name' => 'Test Private Key',
|
|
'private_key' => serverSettingsAuditVultrPrivateKey(),
|
|
]);
|
|
|
|
Http::fake([
|
|
'https://api.vultr.com/v2/regions*' => Http::response(['regions' => [['id' => 'ewr', 'city' => 'New Jersey', 'country' => 'US']], 'meta' => ['links' => ['next' => null]]], 200),
|
|
'https://api.vultr.com/v2/plans*' => Http::response(['plans' => [['id' => 'vc2-1c-1gb', 'vcpu_count' => 1, 'ram' => 1024, 'disk' => 25, 'monthly_cost' => 6, 'locations' => ['ewr']]], 'meta' => ['links' => ['next' => null]]], 200),
|
|
'https://api.vultr.com/v2/os*' => Http::response(['os' => [['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64']], 'meta' => ['links' => ['next' => null]]], 200),
|
|
'https://api.vultr.com/v2/ssh-keys*' => Http::response(['ssh_keys' => [], 'meta' => ['links' => ['next' => null]]], 200),
|
|
'*' => Http::response(['error' => 'unavailable'], 500),
|
|
]);
|
|
|
|
$script = "#cloud-config\npackages:\n - audit-secret-package";
|
|
|
|
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $token->uuid])
|
|
->set('server_name', 'audit-vultr-server')
|
|
->set('selected_region', 'ewr')
|
|
->set('selected_plan', 'vc2-1c-1gb')
|
|
->set('selected_os_id', 2284)
|
|
->set('private_key_id', $privateKey->id)
|
|
->set('cloud_init_script', $script)
|
|
->set('save_cloud_init_script', true)
|
|
->set('cloud_init_script_name', 'Audit script')
|
|
->call('submit');
|
|
|
|
$savedScript = CloudInitScript::query()->where('name', 'Audit script')->firstOrFail();
|
|
$rows = serverSettingsAuditRows('ui.cloud_init_script.created');
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->metadata['cloud_init_script_id'])->toBe($savedScript->id)
|
|
->and(AuditEvent::query()->where('metadata', 'like', '%audit-secret-package%')->exists())->toBeFalse();
|
|
});
|