mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 22:17:19 +00:00
Merge remote-tracking branch 'origin/next' into feat/search-to-envs
This commit is contained in:
commit
d300ddf902
16 files changed
+536
-57
No files matched your search
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function bladeView(string $path): string
|
||||
{
|
||||
return file_get_contents(base_path($path));
|
||||
}
|
||||
|
||||
it('guards deployment log clipboard writes and reports promise failures', function () {
|
||||
$view = bladeView('resources/views/livewire/project/application/deployment/show.blade.php');
|
||||
|
||||
expect($view)
|
||||
->toContain('copyLogs()')
|
||||
->toContain('navigator.clipboard?.writeText')
|
||||
->toContain("Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);")
|
||||
->toContain("Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);")
|
||||
->toContain("Livewire.dispatch('success', ['Logs copied to clipboard.']);");
|
||||
|
||||
expect(Str::between($view, 'copyLogs() {', 'downloadLogs()'))
|
||||
->toContain('navigator.clipboard?.writeText(content).then(() =>')
|
||||
->not->toContain("navigator.clipboard.writeText(content);\n Livewire.dispatch('success'");
|
||||
});
|
||||
|
||||
it('guards shared log clipboard writes and handles Livewire preparation failures', function () {
|
||||
$view = bladeView('resources/views/livewire/project/shared/get-logs.blade.php');
|
||||
|
||||
expect($view)
|
||||
->toContain('navigator.clipboard?.writeText')
|
||||
->toContain("Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);")
|
||||
->toContain("Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);")
|
||||
->toContain("Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);")
|
||||
->toContain("Livewire.dispatch('success', ['Logs copied to clipboard.']);");
|
||||
|
||||
expect($view)
|
||||
->toContain('$wire.copyLogs().then(logs =>')
|
||||
->toContain('}).catch(() => {')
|
||||
->not->toContain('navigator.clipboard.writeText(logs);');
|
||||
});
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
use App\Livewire\Settings\ScheduledJobs;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SchedulerLogParser;
|
||||
@@ -13,6 +19,35 @@ use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function withIsolatedScheduledLogsForMonitoringTest(callable $callback): mixed
|
||||
{
|
||||
$logDir = storage_path('logs');
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$renamed = [];
|
||||
foreach (glob($logDir.'/scheduled-*.log') as $log) {
|
||||
$tmp = $log.'.scheduled-jobs-test-bak';
|
||||
rename($log, $tmp);
|
||||
$renamed[$tmp] = $log;
|
||||
}
|
||||
|
||||
try {
|
||||
return $callback($logDir.'/scheduled-'.now()->format('Y-m-d').'.log');
|
||||
} finally {
|
||||
foreach (glob($logDir.'/scheduled-*.log') as $log) {
|
||||
@unlink($log);
|
||||
}
|
||||
|
||||
foreach ($renamed as $tmp => $original) {
|
||||
if (file_exists($tmp)) {
|
||||
rename($tmp, $original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Create root team (id 0) and root user
|
||||
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
|
||||
@@ -270,3 +305,96 @@ test('skipped jobs show fallback when resource is deleted', function () {
|
||||
rename($tmp, $original);
|
||||
}
|
||||
});
|
||||
|
||||
test('skipped service database backups render with service backup link', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'environment_id' => $environment->id,
|
||||
]);
|
||||
$serviceDatabase = ServiceDatabase::create([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'service-postgres',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'custom_type' => 'postgresql',
|
||||
]);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'frequency' => '0 * * * *',
|
||||
'database_id' => $serviceDatabase->id,
|
||||
'database_type' => $serviceDatabase->getMorphClass(),
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $service, $serviceDatabase) {
|
||||
file_put_contents(
|
||||
$logPath,
|
||||
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n"
|
||||
);
|
||||
|
||||
$expectedUrl = route('project.service.database.backups', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
'stack_service_uuid' => $serviceDatabase->uuid,
|
||||
]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->assertOk()
|
||||
->assertSee('service-postgres')
|
||||
->assertSeeHtml('href="'.$expectedUrl.'"');
|
||||
});
|
||||
});
|
||||
|
||||
test('skipped standalone database backups keep standalone backup link', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'standalone-postgres',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'frequency' => '0 * * * *',
|
||||
'database_id' => $database->id,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $database) {
|
||||
file_put_contents(
|
||||
$logPath,
|
||||
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n"
|
||||
);
|
||||
|
||||
$expectedUrl = route('project.database.backup.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->assertOk()
|
||||
->assertSee('standalone-postgres')
|
||||
->assertSeeHtml('href="'.$expectedUrl.'"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Boarding\Index as BoardingIndex;
|
||||
use App\Livewire\Server\New\ByIp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'Test SSH Key',
|
||||
'description' => 'Test SSH Key',
|
||||
'private_key' => 'test-private-key',
|
||||
'team_id' => $this->team->id,
|
||||
]));
|
||||
|
||||
$token = $this->user->createToken('write-token', ['write']);
|
||||
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
||||
$this->token = $token->plainTextToken;
|
||||
});
|
||||
|
||||
it('creates a server through the API with a dotted SSH username', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers', [
|
||||
'name' => 'Dotted User Server',
|
||||
'ip' => '192.0.2.10',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'user' => 'deploy.user',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'ip' => '192.0.2.10',
|
||||
'user' => 'deploy.user',
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates a server through the API with a dotted SSH username', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'user' => 'deploy',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$server->uuid, [
|
||||
'user' => 'deploy.user',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($server->fresh()->user)->toBe('deploy.user');
|
||||
});
|
||||
|
||||
it('rejects unsafe SSH usernames when creating a server through the API', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers', [
|
||||
'name' => 'Unsafe User Server',
|
||||
'ip' => '192.0.2.11',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'user' => 'deploy$user',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('errors.user.0', 'The User may only contain letters, numbers, dots, hyphens, and underscores.');
|
||||
});
|
||||
|
||||
it('rejects unsafe SSH usernames through the API', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$server->uuid, [
|
||||
'user' => 'deploy$user',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['user']]);
|
||||
$response->assertJsonPath('errors.user.0', 'The User may only contain letters, numbers, dots, hyphens, and underscores.');
|
||||
});
|
||||
|
||||
it('allows dotted SSH usernames in the server creation form', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->privateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Dotted User Server')
|
||||
->set('ip', '192.0.2.20')
|
||||
->set('user', 'deploy.user')
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors(['user']);
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'ip' => '192.0.2.20',
|
||||
'user' => 'deploy.user',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unsafe SSH usernames in the server creation form', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->privateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Unsafe User Server')
|
||||
->set('ip', '192.0.2.21')
|
||||
->set('user', 'deploy$user')
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasErrors(['user' => ['regex']]);
|
||||
});
|
||||
|
||||
it('rejects unsafe SSH usernames during onboarding server creation', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Livewire::test(BoardingIndex::class)
|
||||
->set('createdPrivateKey', $this->privateKey)
|
||||
->set('remoteServerName', 'Unsafe User Server')
|
||||
->set('remoteServerHost', '192.0.2.30')
|
||||
->set('remoteServerPort', 22)
|
||||
->set('remoteServerUser', 'deploy$user')
|
||||
->call('saveServer')
|
||||
->assertHasErrors([
|
||||
'remoteServerUser' => [
|
||||
'regex',
|
||||
'The SSH User may only contain letters, numbers, dots, hyphens, and underscores.',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unsafe SSH usernames during onboarding server validation', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'user' => 'deploy',
|
||||
]);
|
||||
|
||||
Livewire::test(BoardingIndex::class)
|
||||
->set('createdServer', $server)
|
||||
->set('remoteServerPort', 22)
|
||||
->set('remoteServerUser', 'deploy$user')
|
||||
->call('saveAndValidateServer')
|
||||
->assertHasErrors([
|
||||
'remoteServerUser' => [
|
||||
'regex',
|
||||
'The SSH User may only contain letters, numbers, dots, hyphens, and underscores.',
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
it('provides shared validation rules for SSH usernames', function () {
|
||||
expect(ValidationPatterns::SERVER_USERNAME_PATTERN)->toBe('/^[a-zA-Z0-9._-]+$/');
|
||||
expect(ValidationPatterns::serverUsernameRules())->toContain('regex:'.ValidationPatterns::SERVER_USERNAME_PATTERN);
|
||||
|
||||
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy.user'))->toBe(1);
|
||||
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy$user'))->toBe(0);
|
||||
});
|
||||
|
||||
it('preserves dots when sanitizing server SSH usernames', function () {
|
||||
$server = new Server;
|
||||
$server->user = 'deploy.user';
|
||||
|
||||
expect($server->user)->toBe('deploy.user');
|
||||
});
|
||||
Reference in new issue
Block a user