mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 17:44:36 +08:00
221 lines
11 KiB
PHP
221 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Project\Shared\GetLogs;
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\StandaloneClickhouse;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandaloneDragonfly;
|
|
use App\Models\StandaloneKeydb;
|
|
use App\Models\StandaloneMariadb;
|
|
use App\Models\StandaloneMongodb;
|
|
use App\Models\StandaloneMysql;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
const GET_LOGS_EXPORT_CANARY = 'coolify-canary-exported-env-value';
|
|
|
|
beforeEach(function () {
|
|
Server::flushIdentityMap();
|
|
$user = User::factory()->create();
|
|
$team = Team::factory()->create();
|
|
$user->teams()->attach($team, ['role' => 'owner']);
|
|
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
|
]);
|
|
$this->server->settings->fill(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false])->save();
|
|
$this->server = Server::with('settings')->findOrFail($this->server->id);
|
|
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
|
|
$destinationAttributes = ['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()];
|
|
|
|
$this->application = Application::factory()->create(['environment_id' => $environment->id, ...$destinationAttributes]);
|
|
$this->service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $this->server->id, ...$destinationAttributes]);
|
|
$this->serviceApplication = ServiceApplication::create(['name' => 'web', 'service_id' => $this->service->id]);
|
|
|
|
foreach ([$this->application, $this->service] as $resource) {
|
|
EnvironmentVariable::query()->create([
|
|
'key' => 'EXPORT_CANARY',
|
|
'value' => GET_LOGS_EXPORT_CANARY,
|
|
'resourceable_type' => $resource->getMorphClass(),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
|
|
$this->actingAs($user);
|
|
session(['currentTeam' => $team]);
|
|
|
|
Process::fake(['*' => Process::result(output: '2026-09-28T10:00:01Z booted with '.GET_LOGS_EXPORT_CANARY)]);
|
|
});
|
|
|
|
it('redacts the resource environment values when copying or downloading logs', function (string $resource, string $action) {
|
|
$parameters = $resource === 'application'
|
|
? ['resource' => $this->application]
|
|
: ['resource' => $this->service, 'servicesubtype' => $this->serviceApplication];
|
|
|
|
Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => 'test-container', ...$parameters])
|
|
->call($action)
|
|
->assertReturned('2026-09-28T10:00:01Z booted with '.REDACTED);
|
|
})->with(['application', 'service'])->with(['copyLogs', 'downloadAllLogs']);
|
|
|
|
it('redacts stored and effective database passwords in exported logs', function (string $path, bool $remote) {
|
|
$database = StandalonePostgresql::query()->create([
|
|
'name' => 'log-redaction-database',
|
|
'environment_id' => $this->application->environment_id,
|
|
'destination_id' => $this->application->destination_id,
|
|
'destination_type' => $this->application->destination_type,
|
|
'postgres_password' => 'xy',
|
|
'status' => 'running:healthy',
|
|
]);
|
|
EnvironmentVariable::query()->create([
|
|
'key' => 'POSTGRES_PASSWORD',
|
|
'value' => $remote ? '{{vault.DB_PASSWORD}}' : 'effective-database-canary',
|
|
'resourceable_type' => $database->getMorphClass(),
|
|
'resourceable_id' => $database->id,
|
|
'is_preview' => false,
|
|
]);
|
|
if ($remote) {
|
|
Http::preventStrayRequests();
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['DB_PASSWORD' => 'effective-database-canary']),
|
|
]);
|
|
$token = IntegrationToken::factory()->create([
|
|
'team_id' => $database->team()->id,
|
|
'provider' => 'doppler',
|
|
'capabilities' => ['secrets'],
|
|
]);
|
|
$database->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
|
}
|
|
Process::fake(fn ($process) => match (true) {
|
|
str_contains(remoteProcessScript($process), 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])),
|
|
str_contains(remoteProcessScript($process), 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])),
|
|
str_contains(remoteProcessScript($process), 'docker logs') => Process::result(output: 'booted xy effective-database-canary'),
|
|
default => Process::result(output: ''),
|
|
});
|
|
|
|
if ($path === 'api') {
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
|
$user = auth()->user();
|
|
$accessToken = $user->createToken('database-logs', ['read', 'read:sensitive']);
|
|
$accessToken->accessToken->update(['team_id' => $database->team()->id]);
|
|
$token = $accessToken->plainTextToken;
|
|
auth()->forgetGuards();
|
|
$this->withToken($token)->getJson("/api/v1/databases/{$database->uuid}/logs")
|
|
->assertOk()->assertJsonPath('logs', 'booted '.REDACTED.' '.REDACTED);
|
|
} else {
|
|
Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => $database->uuid, 'resource' => $database])
|
|
->call($path)->assertReturned('booted '.REDACTED.' '.REDACTED);
|
|
}
|
|
})->with(['copyLogs', 'downloadAllLogs', 'api'])->with([false, true]);
|
|
|
|
it('still exports database logs when its remote secret source fails', function (string $path) {
|
|
$database = StandalonePostgresql::query()->create([
|
|
'name' => 'log-redaction-failing-source',
|
|
'environment_id' => $this->application->environment_id,
|
|
'destination_id' => $this->application->destination_id,
|
|
'destination_type' => $this->application->destination_type,
|
|
'postgres_password' => 'xy',
|
|
'status' => 'running:healthy',
|
|
]);
|
|
EnvironmentVariable::query()->create([
|
|
'key' => 'POSTGRES_PASSWORD',
|
|
'value' => '{{vault.DB_PASSWORD}}',
|
|
'resourceable_type' => $database->getMorphClass(),
|
|
'resourceable_id' => $database->id,
|
|
'is_preview' => false,
|
|
]);
|
|
Http::preventStrayRequests();
|
|
Http::fake(['https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['message' => 'down'], 500)]);
|
|
$token = IntegrationToken::factory()->create([
|
|
'team_id' => $database->team()->id,
|
|
'provider' => 'doppler',
|
|
'capabilities' => ['secrets'],
|
|
]);
|
|
$database->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
|
Process::fake(fn ($process) => match (true) {
|
|
str_contains(remoteProcessScript($process), 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])),
|
|
str_contains(remoteProcessScript($process), 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])),
|
|
str_contains(remoteProcessScript($process), 'docker logs') => Process::result(output: 'booted xy {{vault.DB_PASSWORD}}'),
|
|
default => Process::result(output: ''),
|
|
});
|
|
|
|
if ($path === 'api') {
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
|
$accessToken = auth()->user()->createToken('database-logs', ['read', 'read:sensitive']);
|
|
$accessToken->accessToken->update(['team_id' => $database->team()->id]);
|
|
auth()->forgetGuards();
|
|
$this->withToken($accessToken->plainTextToken)->getJson("/api/v1/databases/{$database->uuid}/logs")
|
|
->assertOk()->assertJsonPath('logs', 'booted '.REDACTED.' '.REDACTED);
|
|
} else {
|
|
Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => $database->uuid, 'resource' => $database])
|
|
->call($path)->assertReturned('booted '.REDACTED.' '.REDACTED);
|
|
}
|
|
})->with(['copyLogs', 'downloadAllLogs', 'api']);
|
|
|
|
it('redacts database environment values with the same rules as other resources', function (bool $redactAll, string $expected) {
|
|
$database = StandalonePostgresql::query()->create([
|
|
'name' => 'log-redaction-rules-database',
|
|
'environment_id' => $this->application->environment_id,
|
|
'destination_id' => $this->application->destination_id,
|
|
'destination_type' => $this->application->destination_type,
|
|
'postgres_password' => 'xy',
|
|
]);
|
|
$database->update(['redact_all_env_values_in_logs' => $redactAll]);
|
|
foreach ([['TZ', 'UTC', false], ['APP_MODE', 'production-database-mode', false], ['LOCKED', 'q7', true]] as [$key, $value, $locked]) {
|
|
EnvironmentVariable::query()->create([
|
|
'key' => $key,
|
|
'value' => $value,
|
|
'resourceable_type' => $database->getMorphClass(),
|
|
'resourceable_id' => $database->id,
|
|
'is_preview' => false,
|
|
'is_shown_once' => $locked,
|
|
]);
|
|
}
|
|
|
|
$secrets = EnvironmentVariable::logRedactionSecretsFor($database->fresh());
|
|
|
|
expect(sanitizeLogsForExport('tz UTC mode production-database-mode locked q7 password xy', $secrets))->toBe($expected);
|
|
})->with([
|
|
'hide all values' => [true, 'tz UTC mode '.REDACTED.' locked '.REDACTED.' password '.REDACTED],
|
|
'hide locked values only' => [false, 'tz UTC mode production-database-mode locked '.REDACTED.' password '.REDACTED],
|
|
]);
|
|
|
|
it('redacts stored database credentials even without environment variables', function (string $model, array $attributes) {
|
|
$database = new $model($attributes);
|
|
if ($database instanceof StandaloneMongodb) {
|
|
$database->mongo_initdb_root_password = encrypt($attributes['mongo_initdb_root_password']);
|
|
}
|
|
$database->setRelation('runtime_environment_variables', collect());
|
|
|
|
$secrets = EnvironmentVariable::logRedactionSecretsFor($database);
|
|
|
|
expect(sanitizeLogsForExport('booted '.implode(' ', array_values($attributes)), $secrets))
|
|
->toBe('booted '.implode(' ', array_fill(0, count($attributes), REDACTED)));
|
|
})->with([
|
|
'postgres' => [StandalonePostgresql::class, ['postgres_password' => 'xy']],
|
|
'mysql' => [StandaloneMysql::class, ['mysql_password' => 'xy', 'mysql_root_password' => 'zw']],
|
|
'mariadb' => [StandaloneMariadb::class, ['mariadb_password' => 'xy', 'mariadb_root_password' => 'zw']],
|
|
'mongodb' => [StandaloneMongodb::class, ['mongo_initdb_root_password' => 'xy']],
|
|
'clickhouse' => [StandaloneClickhouse::class, ['clickhouse_admin_password' => 'xy']],
|
|
'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'xy']],
|
|
'dragonfly' => [StandaloneDragonfly::class, ['dragonfly_password' => 'xy']],
|
|
]);
|