mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Closes #12391 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
383 lines
19 KiB
PHP
383 lines
19 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
|
|
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
|
|
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
|
|
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
|
|
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
|
|
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
|
|
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
|
|
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
|
|
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
|
|
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
|
|
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
|
|
use App\Livewire\Project\Database\Sqlite\General as SqliteGeneral;
|
|
use App\Models\Environment;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\SharedEnvironmentVariable;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandaloneRedis;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->owner = User::factory()->create();
|
|
$this->member = User::factory()->create();
|
|
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
|
|
$this->team->members()->attach($this->member->id, ['role' => 'member']);
|
|
|
|
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$this->destination = StandaloneDocker::where('server_id', $server->id)->first();
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
});
|
|
|
|
dataset('database-config-components', [
|
|
'postgresql' => ['create_standalone_postgresql', PostgresqlGeneral::class, [
|
|
'postgres_conf' => 'password_encryption = secret-config-value',
|
|
'init_scripts' => [['index' => 0, 'filename' => 'init.sql', 'content' => "CREATE USER app PASSWORD 'secret-config-value';"]],
|
|
], ['postgresConf', 'initScripts']],
|
|
'mysql' => ['create_standalone_mysql', MysqlGeneral::class, ['mysql_conf' => 'password=secret-config-value'], ['mysqlConf']],
|
|
'mariadb' => ['create_standalone_mariadb', MariadbGeneral::class, ['mariadb_conf' => 'password=secret-config-value'], ['mariadbConf']],
|
|
'mongodb' => ['create_standalone_mongodb', MongodbGeneral::class, ['mongo_conf' => 'keyFile: secret-config-value'], ['mongoConf']],
|
|
'redis' => ['create_standalone_redis', RedisGeneral::class, ['redis_conf' => 'requirepass secret-config-value'], ['redisConf']],
|
|
'keydb' => ['create_standalone_keydb', KeydbGeneral::class, ['keydb_conf' => 'requirepass secret-config-value'], ['keydbConf']],
|
|
]);
|
|
|
|
function createDatabaseWithConfig(string $createDatabase, array $config): mixed
|
|
{
|
|
$database = $createDatabase(test()->environment->id, test()->destination);
|
|
$database->forceFill($config)->save();
|
|
|
|
return $database->fresh();
|
|
}
|
|
|
|
function lockRedisPassword(StandaloneRedis $redis, ?string $value = null): void
|
|
{
|
|
$redis->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail()
|
|
->forceFill(array_filter(['is_shown_once' => true, 'value' => $value], fn ($attribute) => $attribute !== null))
|
|
->save();
|
|
}
|
|
|
|
it('keeps custom configs and init scripts out of member state and html', function (string $createDatabase, string $generalClass, array $config, array $properties) {
|
|
$database = createDatabaseWithConfig($createDatabase, $config);
|
|
|
|
$this->actingAs($this->member);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$general = Livewire::test($generalClass, ['database' => $database]);
|
|
if (method_exists($generalClass, 'refresh')) {
|
|
$general->call('refresh');
|
|
}
|
|
foreach ($properties as $property) {
|
|
$general->assertSet($property, null);
|
|
}
|
|
expect(json_encode($general->snapshot))->not->toContain('secret-config-value')
|
|
->and($general->html())->not->toContain('secret-config-value')
|
|
->and($general->html())->toContain('Hidden (only admins can view)');
|
|
})->with('database-config-components');
|
|
|
|
it('keeps custom configs out of a member who switched the session to a team they own', function (string $createDatabase, string $generalClass, array $config, array $properties) {
|
|
$database = createDatabaseWithConfig($createDatabase, $config);
|
|
$ownTeam = Team::factory()->create();
|
|
$ownTeam->members()->attach($this->member->id, ['role' => 'owner']);
|
|
|
|
$this->actingAs($this->member);
|
|
session(['currentTeam' => $ownTeam]);
|
|
|
|
$general = Livewire::test($generalClass, ['database' => $database]);
|
|
foreach ($properties as $property) {
|
|
$general->assertSet($property, null);
|
|
}
|
|
expect(json_encode($general->snapshot))->not->toContain('secret-config-value')
|
|
->and($general->html())->not->toContain('secret-config-value');
|
|
})->with('database-config-components');
|
|
|
|
it('keeps custom configs and init scripts available to an owner', function (string $createDatabase, string $generalClass, array $config, array $properties) {
|
|
$database = createDatabaseWithConfig($createDatabase, $config);
|
|
|
|
$this->actingAs($this->owner);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$general = Livewire::test($generalClass, ['database' => $database]);
|
|
foreach (array_values($config) as $index => $value) {
|
|
$general->assertSet($properties[$index], $value);
|
|
}
|
|
})->with('database-config-components');
|
|
|
|
it('does not let a member save custom configs', function () {
|
|
$database = createDatabaseWithConfig('create_standalone_redis', ['redis_conf' => 'requirepass secret-config-value']);
|
|
|
|
$this->actingAs($this->member);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
Livewire::test(RedisGeneral::class, ['database' => $database])
|
|
->set('redisConf', 'requirepass changed')
|
|
->call('submit');
|
|
|
|
expect($database->fresh()->redis_conf)->toBe('requirepass secret-config-value');
|
|
});
|
|
|
|
it('does not show a locked redis password to an owner in the form or the connection urls', function () {
|
|
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']);
|
|
lockRedisPassword($redis);
|
|
|
|
$this->actingAs($this->owner);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$general = Livewire::test(RedisGeneral::class, ['database' => $redis->fresh()])
|
|
->assertSet('redisPassword', null)
|
|
->assertSet('isPasswordLocked', true);
|
|
expect(json_encode($general->snapshot))->not->toContain('locked-redis-secret')
|
|
->and($general->html())->not->toContain('locked-redis-secret')
|
|
->and($general->html())->toContain('Locked (shown once)');
|
|
|
|
$statusInfo = Livewire::test(RedisStatusInfo::class, ['database' => $redis->fresh()])
|
|
->assertSet('dbUrl', null)
|
|
->assertSet('dbUrlPublic', null);
|
|
expect(json_encode($statusInfo->snapshot))->not->toContain('locked-redis-secret')
|
|
->and($statusInfo->html())->not->toContain('locked-redis-secret')
|
|
->and($statusInfo->html())->toContain('Locked (shown once)');
|
|
});
|
|
|
|
it('does not show a locked shared variable that the redis password reads in the connection urls', function () {
|
|
$redis = create_standalone_redis($this->environment->id, $this->destination);
|
|
SharedEnvironmentVariable::create([
|
|
'key' => 'REDIS_SECRET',
|
|
'value' => 'locked-shared-secret',
|
|
'type' => 'team',
|
|
'team_id' => $this->team->id,
|
|
'is_shown_once' => true,
|
|
]);
|
|
$redis->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail()
|
|
->forceFill(['value' => '{{team.REDIS_SECRET}}'])->save();
|
|
|
|
$this->actingAs($this->owner);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$statusInfo = Livewire::test(RedisStatusInfo::class, ['database' => $redis->fresh()])
|
|
->assertSet('dbUrl', null);
|
|
expect(json_encode($statusInfo->snapshot))->not->toContain('locked-shared-secret');
|
|
});
|
|
|
|
it('does not show a locked REDIS_PASSWORD override in the keydb and dragonfly connection urls', function (string $createDatabase, string $statusInfoClass) {
|
|
$database = $createDatabase($this->environment->id, $this->destination);
|
|
$database->forceFill(['legacy_password_quoting' => false])->save();
|
|
EnvironmentVariable::create([
|
|
'key' => 'REDIS_PASSWORD',
|
|
'value' => 'locked-override-secret',
|
|
'is_shown_once' => true,
|
|
'resourceable_type' => $database::class,
|
|
'resourceable_id' => $database->id,
|
|
]);
|
|
|
|
$this->actingAs($this->owner);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$statusInfo = Livewire::test($statusInfoClass, ['database' => $database->fresh()])
|
|
->assertSet('dbUrl', null);
|
|
expect(json_encode($statusInfo->snapshot))->not->toContain('locked-override-secret')
|
|
->and($statusInfo->html())->toContain('Locked (shown once)');
|
|
})->with([
|
|
'keydb' => ['create_standalone_keydb', KeydbStatusInfo::class],
|
|
'dragonfly' => ['create_standalone_dragonfly', DragonflyStatusInfo::class],
|
|
]);
|
|
|
|
it('keeps a locked redis password when an owner saves the form', function () {
|
|
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']);
|
|
lockRedisPassword($redis);
|
|
|
|
$this->actingAs($this->owner);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
Livewire::test(RedisGeneral::class, ['database' => $redis->fresh()])
|
|
->set('name', 'renamed-redis')
|
|
->call('submit')
|
|
->assertHasNoErrors();
|
|
|
|
$redis = $redis->fresh();
|
|
expect($redis->name)->toBe('renamed-redis')
|
|
->and(EnvironmentVariable::query()->where('resourceable_type', StandaloneRedis::class)
|
|
->where('resourceable_id', $redis->id)->where('key', 'REDIS_PASSWORD')->firstOrFail()->value)
|
|
->toBe('locked-redis-secret');
|
|
});
|
|
|
|
it('redacts a locked redis password and its connection urls in the api', function () {
|
|
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']);
|
|
lockRedisPassword($redis);
|
|
|
|
session(['currentTeam' => $this->team]);
|
|
$token = $this->owner->createToken('locked-test', ['read', 'read:sensitive']);
|
|
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $this->team->id]);
|
|
$headers = ['Authorization' => 'Bearer '.$token->plainTextToken];
|
|
|
|
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$redis->uuid}")->assertOk();
|
|
expect($single->getContent())->not->toContain('locked-redis-secret')
|
|
->and($single->json('internal_db_url'))->toBeNull();
|
|
|
|
$list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk();
|
|
expect($list->getContent())->not->toContain('locked-redis-secret');
|
|
|
|
$resources = $this->withHeaders($headers)->getJson('/api/v1/resources')->assertOk();
|
|
expect($resources->getContent())->not->toContain('locked-redis-secret')
|
|
->and($resources->json('0.uuid'))->toBe($redis->uuid)
|
|
->and($resources->json('0.internal_db_url'))->toBeNull();
|
|
|
|
$environment = $this->withHeaders($headers)
|
|
->getJson("/api/v1/projects/{$this->environment->project->uuid}/{$this->environment->uuid}")
|
|
->assertOk();
|
|
expect($environment->getContent())->not->toContain('locked-redis-secret')
|
|
->and($environment->json('redis.0.uuid'))->toBe($redis->uuid)
|
|
->and($environment->json('redis.0.internal_db_url'))->toBeNull();
|
|
});
|
|
|
|
it('still returns the connection url of an unlocked redis password in the api', function () {
|
|
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'visible-redis-secret']);
|
|
|
|
session(['currentTeam' => $this->team]);
|
|
$token = $this->owner->createToken('unlocked-test', ['read', 'read:sensitive']);
|
|
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $this->team->id]);
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
|
|
->getJson("/api/v1/databases/{$redis->uuid}")
|
|
->assertOk()
|
|
->assertJsonPath('internal_db_url', "redis://default:visible-redis-secret@{$redis->uuid}:6379/0");
|
|
});
|
|
|
|
dataset('database-api-config', [
|
|
'postgresql' => ['create_standalone_postgresql', 'postgresqls', ['postgres_conf' => "primary_conninfo = 'password=secret-config-value'"]],
|
|
'mysql' => ['create_standalone_mysql', 'mysqls', ['mysql_conf' => 'password=secret-config-value']],
|
|
'mariadb' => ['create_standalone_mariadb', 'mariadbs', ['mariadb_conf' => 'password=secret-config-value']],
|
|
'mongodb' => ['create_standalone_mongodb', 'mongodbs', ['mongo_conf' => 'keyFile: secret-config-value']],
|
|
'redis' => ['create_standalone_redis', 'redis', ['redis_conf' => 'masterauth secret-config-value']],
|
|
'keydb' => ['create_standalone_keydb', null, ['keydb_conf' => 'masterauth secret-config-value']],
|
|
'dragonfly' => ['create_standalone_dragonfly', null, []],
|
|
'clickhouse' => ['create_standalone_clickhouse', null, []],
|
|
]);
|
|
|
|
function apiTokenHeaders(User $user, Team $team, array $abilities): array
|
|
{
|
|
$token = $user->createToken('config-test', $abilities);
|
|
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]);
|
|
|
|
return ['Authorization' => 'Bearer '.$token->plainTextToken];
|
|
}
|
|
|
|
it('hides custom configs and docker run options from a token without sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) {
|
|
$database = createDatabaseWithConfig($createDatabase, [
|
|
...$config,
|
|
'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value',
|
|
]);
|
|
session(['currentTeam' => $this->team]);
|
|
$project = $this->environment->project;
|
|
|
|
foreach ([
|
|
[$this->member, ['read']],
|
|
[$this->member, ['read', 'read:sensitive']],
|
|
[$this->owner, ['read']],
|
|
] as [$user, $abilities]) {
|
|
$headers = apiTokenHeaders($user, $this->team, $abilities);
|
|
|
|
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk();
|
|
expect($single->json('uuid'))->toBe($database->uuid)
|
|
->and($single->getContent())->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
|
|
|
|
$list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk();
|
|
expect($list->getContent())->toContain($database->uuid)
|
|
->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
|
|
|
|
if ($environmentRelation !== null) {
|
|
$environment = $this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk();
|
|
expect($environment->getContent())->toContain($database->uuid)
|
|
->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
|
|
}
|
|
}
|
|
})->with('database-api-config');
|
|
|
|
it('returns custom configs and docker run options to an owner token with sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) {
|
|
$database = createDatabaseWithConfig($createDatabase, [
|
|
...$config,
|
|
'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value',
|
|
]);
|
|
session(['currentTeam' => $this->team]);
|
|
$headers = apiTokenHeaders($this->owner, $this->team, ['read', 'read:sensitive']);
|
|
|
|
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk()
|
|
->assertJsonPath('custom_docker_run_options', '--env API_TOKEN=secret-docker-value');
|
|
foreach ($config as $key => $value) {
|
|
$single->assertJsonPath($key, $value);
|
|
}
|
|
|
|
if ($environmentRelation !== null) {
|
|
$project = $this->environment->project;
|
|
$this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk()
|
|
->assertJsonPath("{$environmentRelation}.0.custom_docker_run_options", '--env API_TOKEN=secret-docker-value');
|
|
}
|
|
})->with('database-api-config');
|
|
|
|
dataset('database-docker-options-components', [
|
|
'postgresql' => ['create_standalone_postgresql', PostgresqlGeneral::class],
|
|
'mysql' => ['create_standalone_mysql', MysqlGeneral::class],
|
|
'mariadb' => ['create_standalone_mariadb', MariadbGeneral::class],
|
|
'mongodb' => ['create_standalone_mongodb', MongodbGeneral::class],
|
|
'redis' => ['create_standalone_redis', RedisGeneral::class],
|
|
'keydb' => ['create_standalone_keydb', KeydbGeneral::class],
|
|
'dragonfly' => ['create_standalone_dragonfly', DragonflyGeneral::class],
|
|
'clickhouse' => ['create_standalone_clickhouse', ClickhouseGeneral::class],
|
|
'sqlite' => ['create_standalone_sqlite', SqliteGeneral::class],
|
|
]);
|
|
|
|
it('keeps custom docker run options out of member state and html', function (string $createDatabase, string $generalClass) {
|
|
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
|
|
$ownTeam = Team::factory()->create();
|
|
$ownTeam->members()->attach($this->member->id, ['role' => 'owner']);
|
|
|
|
$this->actingAs($this->member);
|
|
foreach ([$this->team, $ownTeam] as $sessionTeam) {
|
|
session(['currentTeam' => $sessionTeam]);
|
|
|
|
$general = Livewire::test($generalClass, ['database' => $database])
|
|
->assertSet('customDockerRunOptions', null);
|
|
expect(json_encode($general->snapshot))->not->toContain('secret-docker-value')
|
|
->and($general->html())->not->toContain('secret-docker-value')
|
|
->and($general->html())->toContain('Hidden (only admins can view)');
|
|
}
|
|
})->with('database-docker-options-components');
|
|
|
|
it('keeps custom docker run options available to an owner', function (string $createDatabase, string $generalClass) {
|
|
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
|
|
|
|
$this->actingAs($this->owner);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
Livewire::test($generalClass, ['database' => $database])
|
|
->assertSet('customDockerRunOptions', '--env API_TOKEN=secret-docker-value');
|
|
})->with('database-docker-options-components');
|
|
|
|
it('does not let a member save over hidden custom configs and docker run options', function (string $createDatabase, string $generalClass) {
|
|
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
|
|
|
|
$this->actingAs($this->member);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
Livewire::test($generalClass, ['database' => $database])
|
|
->set('name', 'renamed-by-member')
|
|
->call('submit')
|
|
->call('instantSaveAdvanced');
|
|
|
|
$database = $database->fresh();
|
|
expect($database->name)->not->toBe('renamed-by-member')
|
|
->and($database->custom_docker_run_options)->toBe('--env API_TOKEN=secret-docker-value');
|
|
})->with('database-docker-options-components');
|