mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +00:00
Merge remote-tracking branch 'origin/next' into fix/form-state
This commit is contained in:
commit
4401bee941
68 files changed
+2407
-1890
No files matched your search
@@ -80,11 +80,11 @@ it('checks legacy preview deployment configuration hash using preview environmen
|
||||
|
||||
$diff = $application->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isLegacyFallback())->toBeTrue()
|
||||
->and($diff->isChanged())->toBeTrue();
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->count())->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to legacy configuration hash when no deployment snapshot exists', function () {
|
||||
it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$application->isConfigurationChanged(save: true);
|
||||
|
||||
@@ -92,6 +92,10 @@ it('falls back to legacy configuration hash when no deployment snapshot exists',
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
|
||||
->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->isLegacyFallback())->toBeFalse()
|
||||
->and($diff->count())->toBeGreaterThan(0)
|
||||
->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command');
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Livewire\Project\Shared\Destination;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
@@ -10,6 +11,7 @@ use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -65,6 +67,10 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
GetContainersStatus::clearFake();
|
||||
});
|
||||
|
||||
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
|
||||
test('cannot attach another team\'s server + network to own application', function () {
|
||||
try {
|
||||
@@ -98,6 +104,16 @@ describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot attach own network paired with wrong own server', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverA->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('can attach own team\'s server + network to own application', function () {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
|
||||
@@ -121,4 +137,96 @@ describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
|
||||
});
|
||||
|
||||
test('cannot promote own network paired with wrong own server', function () {
|
||||
$originalDestinationId = $this->applicationA->destination_id;
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
|
||||
});
|
||||
|
||||
test('can promote own team network and preserve previous main as additional network', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
$application = $this->applicationA->fresh();
|
||||
$additional = $application->additional_networks;
|
||||
|
||||
expect($application->destination_id)->toBe($this->destinationA2->id);
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
|
||||
});
|
||||
|
||||
test('refresh failures after promote do not roll back promoted destination', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
|
||||
GetContainersStatus::shouldRun()
|
||||
->once()
|
||||
->andThrow(new RuntimeException('refresh failed'));
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
} catch (Throwable $e) {
|
||||
// The refresh failure is intentionally outside the transaction; persistence is the assertion.
|
||||
}
|
||||
|
||||
$application = $this->applicationA->fresh();
|
||||
$additional = $application->additional_networks;
|
||||
|
||||
expect($application->destination_id)->toBe($this->destinationA2->id);
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
|
||||
});
|
||||
|
||||
test('only detaches the promoted network for the selected pivot server', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA2->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination::removeServer', function () {
|
||||
test('only detaches the removed network for the selected pivot server', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('removeServer', $this->destinationA2->id, $this->serverA2->id, 'password');
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA2->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,11 @@ it('scopes scroll teardown to the component so a stale loop cannot leak across d
|
||||
->not->toContain("document.getElementById('logsContainer')")
|
||||
// morph.updated hook only acts on this component's own DOM.
|
||||
->toContain('this.$root.contains(el)')
|
||||
// Global Livewire hook is unregistered when Alpine tears down.
|
||||
->toContain('morphUpdatedCleanup: null')
|
||||
->toContain("this.morphUpdatedCleanup = Livewire.hook('morph.updated'")
|
||||
->toContain("typeof this.morphUpdatedCleanup === 'function'")
|
||||
->toContain('this.morphUpdatedCleanup()')
|
||||
// Continuation timeout is tracked so it can be cancelled.
|
||||
->toContain('scrollTimeout');
|
||||
});
|
||||
@@ -127,7 +127,7 @@ describe('GitHub Private Repository Component', function () {
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('loadRepositories does not mint tokens for another teams system wide github app', function () {
|
||||
test('mount lists another teams system wide github app', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$systemWideGithubApp = GithubApp::create([
|
||||
@@ -147,13 +147,43 @@ describe('GitHub Private Repository Component', function () {
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']);
|
||||
|
||||
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
expect($component->get('github_apps')->pluck('id')->all())
|
||||
->toContain($this->githubApp->id)
|
||||
->toContain($systemWideGithubApp->id);
|
||||
});
|
||||
|
||||
test('loadRepositories can use another teams system wide github app', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$systemWideGithubApp = GithubApp::create([
|
||||
'name' => 'System Wide GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'system-client-id',
|
||||
'client_secret' => 'system-client-secret',
|
||||
'webhook_secret' => 'system-webhook-secret',
|
||||
'private_key_id' => $victimPrivateKey->id,
|
||||
'team_id' => $victimTeam->id,
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'system-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
fakeGithubHttp($repos);
|
||||
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $systemWideGithubApp->id)
|
||||
)->toThrow(ModelNotFoundException::class);
|
||||
|
||||
Http::assertNothingSent();
|
||||
->assertSet('current_step', 'repository')
|
||||
->assertSet('total_repositories_count', 1)
|
||||
->assertSet('selected_repository_id', 1);
|
||||
});
|
||||
|
||||
test('github installation token is not stored as public component state', function () {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\Source\Github\Change;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -19,9 +20,45 @@ beforeEach(function () {
|
||||
// Set current team
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
InstanceSettings::forceCreate([
|
||||
'id' => 0,
|
||||
'fqdn' => null,
|
||||
'public_ipv4' => null,
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
function validPrivateKey(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
describe('GitHub Source Change Component', function () {
|
||||
test('all github app form controls declare explicit authorization', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
|
||||
|
||||
preg_match_all(
|
||||
'/<x-forms\.(button|input|select|checkbox)\b(?![^>]*\bcanGate=)[^>]*>/s',
|
||||
$view,
|
||||
$matches,
|
||||
PREG_OFFSET_CAPTURE
|
||||
);
|
||||
|
||||
$missingAuthorization = collect($matches[0])
|
||||
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
|
||||
->all();
|
||||
|
||||
expect($missingAuthorization)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('can mount with newly created github app with null app_id', function () {
|
||||
// Create a GitHub app without app_id (simulating a newly created source)
|
||||
$githubApp = GithubApp::create([
|
||||
@@ -47,10 +84,69 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSet('privateKeyId', null);
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'fqdn' => null,
|
||||
'public_ipv4' => null,
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->assertSet('webhook_endpoint', 'http://localhost:8000');
|
||||
});
|
||||
|
||||
test('custom webhook endpoint is selected explicitly with a checkbox', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'fqdn' => 'http://staging.example.com',
|
||||
'public_ipv4' => '84.1.202.183',
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->assertSet('use_custom_webhook_endpoint', false)
|
||||
->set('custom_webhook_endpoint', 'https://staging.example.com')
|
||||
->set('use_custom_webhook_endpoint', true)
|
||||
->assertSet('webhook_endpoint', 'http://staging.example.com')
|
||||
->assertSet('custom_webhook_endpoint', 'https://staging.example.com')
|
||||
->assertSet('use_custom_webhook_endpoint', true)
|
||||
->assertSee('Use custom webhook endpoint')
|
||||
->assertSee('Selected endpoint')
|
||||
->assertSee('Custom endpoint')
|
||||
->assertSee('createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint');
|
||||
});
|
||||
|
||||
test('can mount with fully configured github app', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -84,7 +180,7 @@ describe('GitHub Source Change Component', function () {
|
||||
test('can update github app from null to valid values', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -157,8 +253,8 @@ describe('GitHub Source Change Component', function () {
|
||||
|
||||
// Verify the database was updated
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->app_id)->toBe('1234567890');
|
||||
expect($githubApp->installation_id)->toBe('1234567890');
|
||||
expect($githubApp->app_id)->toBe(1234567890);
|
||||
expect($githubApp->installation_id)->toBe(1234567890);
|
||||
});
|
||||
|
||||
test('checkPermissions validates required fields', function () {
|
||||
@@ -179,6 +275,8 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSuccessful()
|
||||
->call('checkPermissions')
|
||||
->assertDispatched('error', function ($event, $message) {
|
||||
$message = is_array($message) ? implode(' ', $message) : $message;
|
||||
|
||||
return str_contains($message, 'App ID') && str_contains($message, 'Private Key');
|
||||
});
|
||||
});
|
||||
@@ -202,6 +300,8 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSuccessful()
|
||||
->call('checkPermissions')
|
||||
->assertDispatched('error', function ($event, $message) {
|
||||
$message = is_array($message) ? implode(' ', $message) : $message;
|
||||
|
||||
return str_contains($message, 'Private Key not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,8 +126,7 @@ it('does not render environment variable secret values', function () {
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('changed')
|
||||
->assertSee('Set')
|
||||
->assertSee('••••••••')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('old-secret')
|
||||
->assertDontSee('new-secret');
|
||||
@@ -150,9 +149,9 @@ it('renders added environment variables as set without exposing secret values',
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('From')
|
||||
->assertSee('Not set')
|
||||
->assertSee('-')
|
||||
->assertSee('To')
|
||||
->assertSee('Set')
|
||||
->assertSee('••••••••')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\Team;
|
||||
use Database\Seeders\ProductionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('creates the root team before seeding the localhost server and predefined shared variables', function () {
|
||||
config([
|
||||
'broadcasting.default' => 'log',
|
||||
'constants.coolify.is_windows_docker_desktop' => true,
|
||||
]);
|
||||
Queue::fake();
|
||||
StartProxy::shouldRun()->andReturn('OK');
|
||||
|
||||
Server::creating(function (Server $server) {
|
||||
if ((int) $server->getKey() === 0) {
|
||||
expect(Team::find(0))->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
Server::created(function (Server $server) {
|
||||
SslCertificate::create([
|
||||
'server_id' => $server->id,
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'ssl_certificate' => 'certificate',
|
||||
'ssl_private_key' => 'private-key',
|
||||
'valid_until' => now()->addYear(),
|
||||
'is_ca_certificate' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->seed(ProductionSeeder::class);
|
||||
|
||||
$rootTeam = Team::find(0);
|
||||
$localhostServer = Server::find(0);
|
||||
|
||||
expect($rootTeam)->not->toBeNull()
|
||||
->and($localhostServer)->not->toBeNull()
|
||||
->and($localhostServer->team_id)->toBe(0);
|
||||
|
||||
expect(SharedEnvironmentVariable::query()
|
||||
->where('type', 'server')
|
||||
->where('server_id', 0)
|
||||
->where('team_id', 0)
|
||||
->pluck('key')
|
||||
->all()
|
||||
)->toContain('COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME');
|
||||
|
||||
instanceSettings()->update(['is_registration_enabled' => true]);
|
||||
|
||||
$rootUser = app(CreateNewUser::class)->create([
|
||||
'name' => 'Root User',
|
||||
'email' => 'root@example.com',
|
||||
'password' => 'Password123!',
|
||||
'password_confirmation' => 'Password123!',
|
||||
]);
|
||||
|
||||
expect(Team::whereKey(0)->count())->toBe(1)
|
||||
->and($rootUser->teams()->where('team_id', 0)->exists())->toBeTrue();
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('database last_online_at is updated when status unchanged', function () {
|
||||
test('database last_online_at is not updated when status is unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
'last_online_at' => now()->subMinutes(5),
|
||||
]);
|
||||
@@ -40,15 +42,13 @@ test('database last_online_at is updated when status unchanged', function () {
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// last_online_at should be updated even though status didn't change
|
||||
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
|
||||
expect((string) $database->last_online_at)->toBe((string) $oldLastOnline);
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database status is updated when container status changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
@@ -79,8 +79,7 @@ test('database status is updated when container status changes', function () {
|
||||
|
||||
test('database is not marked exited when containers list is empty', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
@@ -99,3 +98,31 @@ test('database is not marked exited when containers list is empty', function ()
|
||||
// Status should remain running, NOT be set to exited
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
function createPushUpdatePostgresql(Team $team, array $attributes = []): StandalonePostgresql
|
||||
{
|
||||
$lastOnlineAt = $attributes['last_online_at'] ?? null;
|
||||
unset($attributes['last_online_at']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandalonePostgresql::create(array_merge([
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'postgres-'.str()->random(8),
|
||||
'postgres_password' => 'secret',
|
||||
'status' => 'exited',
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'environment_id' => $environment->id,
|
||||
], $attributes));
|
||||
|
||||
if ($lastOnlineAt !== null) {
|
||||
$database->forceFill(['last_online_at' => $lastOnlineAt])->saveQuietly();
|
||||
}
|
||||
|
||||
return $database;
|
||||
}
|
||||
@@ -50,6 +50,39 @@ it('does not dispatch storage check when disk usage is below threshold', functio
|
||||
Queue::assertNotPushed(ServerStorageCheckJob::class);
|
||||
});
|
||||
|
||||
it('clears stale storage cache when disk usage drops below threshold', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$storageCacheKey = 'storage-check:'.$server->id;
|
||||
|
||||
Cache::put($storageCacheKey, 85, 600);
|
||||
|
||||
$belowThresholdData = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $belowThresholdData);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(ServerStorageCheckJob::class);
|
||||
expect(Cache::missing($storageCacheKey))->toBeTrue();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$aboveThresholdData = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 85],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $aboveThresholdData);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 85;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch storage check when disk percentage is unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
@@ -4,17 +4,21 @@ use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('containers with empty service subId are skipped', function () {
|
||||
$server = Server::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
$serviceApp = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'app-'.str()->random(8),
|
||||
]);
|
||||
|
||||
$data = [
|
||||
@@ -44,12 +48,15 @@ test('containers with empty service subId are skipped', function () {
|
||||
});
|
||||
|
||||
test('containers with valid service subId are processed', function () {
|
||||
$server = Server::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
$serviceApp = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'app-'.str()->random(8),
|
||||
]);
|
||||
|
||||
$data = [
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('dispatches scheduled tasks across chunks', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
|
||||
Queue::fake();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
'docker_cleanup_frequency' => '0 * * * *',
|
||||
]);
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
ScheduledTask::factory()
|
||||
->count(101)
|
||||
->create([
|
||||
'team_id' => $team->id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '* * * * *',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
(new ScheduledJobManager)->handle();
|
||||
|
||||
Queue::assertPushed(ScheduledTaskJob::class, 101);
|
||||
});
|
||||
|
||||
it('skips expensive dispatch for non-due schedules while seeding dedup cache', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
|
||||
Queue::fake();
|
||||
|
||||
$application = createScheduledTaskApplication();
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $application->environment->project->team_id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '0 2 * * *',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
(new ScheduledJobManager)->handle();
|
||||
|
||||
Queue::assertNotPushed(ScheduledTaskJob::class);
|
||||
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not query relationships when constructing scheduled task jobs', function () {
|
||||
$application = createScheduledTaskApplication();
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $application->environment->project->team_id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '* * * * *',
|
||||
'enabled' => true,
|
||||
])->fresh();
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$job = new ScheduledTaskJob($task);
|
||||
|
||||
expect(DB::getQueryLog())->toBeEmpty()
|
||||
->and($job->queue)->toBe(crons_queue())
|
||||
->and($job->timeout)->toBe(300);
|
||||
});
|
||||
|
||||
function createScheduledTaskApplication(): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
'docker_cleanup_frequency' => '0 * * * *',
|
||||
]);
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'status' => 'running',
|
||||
]);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\SentinelController;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -11,6 +13,8 @@ use Illuminate\Support\Facades\Queue;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.store' => 'array']);
|
||||
|
||||
Queue::fake();
|
||||
Cache::flush();
|
||||
|
||||
@@ -45,6 +49,25 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra
|
||||
|
||||
$running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
|
||||
|
||||
it('skips dispatch decision when sentinel lock acquisition times out', function () use ($running) {
|
||||
$lock = Mockery::mock();
|
||||
$lock->shouldReceive('block')
|
||||
->once()
|
||||
->with(5, Mockery::type('callable'))
|
||||
->andThrow(LockTimeoutException::class);
|
||||
|
||||
Cache::shouldReceive('lock')
|
||||
->once()
|
||||
->with('sentinel:push-lock:'.$this->server->id, 10)
|
||||
->andReturn($lock);
|
||||
|
||||
$controller = new SentinelController;
|
||||
$method = new ReflectionMethod($controller, 'shouldDispatchUpdate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($controller, $this->server, sentinelPayload($running())))->toBeFalse();
|
||||
});
|
||||
|
||||
it('dispatches the job on the first push', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
@@ -69,6 +92,43 @@ it('updates the heartbeat even when the job is skipped', function () use ($runni
|
||||
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('accepts an empty container list as a heartbeat when no containers are running', function () {
|
||||
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload([]))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('rejects malformed sentinel payloads before touching server state', function (array $payload) {
|
||||
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
||||
$originalHeartbeat = $this->server->fresh()->sentinel_updated_at;
|
||||
|
||||
pushSentinel($this->token, $payload)
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Validation failed.')
|
||||
->assertJsonValidationErrors('containers');
|
||||
|
||||
Queue::assertNotPushed(PushServerUpdateJob::class);
|
||||
expect($this->server->fresh()->sentinel_updated_at)->toBe($originalHeartbeat);
|
||||
expect(Cache::has('sentinel:push-hash:'.$this->server->id))->toBeFalse();
|
||||
expect(Cache::has('sentinel:push-force:'.$this->server->id))->toBeFalse();
|
||||
})->with([
|
||||
'missing containers' => [[]],
|
||||
'non-array containers' => [['containers' => 'not-an-array']],
|
||||
]);
|
||||
|
||||
it('guards the dedupe decision with a server scoped atomic cache lock', function () {
|
||||
$controller = file_get_contents(app_path('Http/Controllers/Api/SentinelController.php'));
|
||||
|
||||
expect($controller)
|
||||
->toContain('$lockKey = "sentinel:push-lock:{$server->id}";')
|
||||
->toContain('Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool')
|
||||
->toContain('Cache::put($hashKey, $hash, now()->addDay())')
|
||||
->toContain("Cache::put(\$forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300))");
|
||||
});
|
||||
|
||||
it('dispatches the job when container state changes', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
@@ -78,6 +138,16 @@ it('dispatches the job when container state changes', function () use ($running)
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
||||
});
|
||||
|
||||
it('ignores health status changes while container lifecycle state is unchanged', function () {
|
||||
$healthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
|
||||
$unhealthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'unhealthy']];
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($healthy))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($unhealthy))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('ignores disk percentage changes (excluded from the hash)', function () use ($running) {
|
||||
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<?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\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -116,177 +114,3 @@ it('returns false and runs no process when multiplexing is globally disabled', f
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
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\n".
|
||||
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n".
|
||||
"333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4\n"),
|
||||
'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 old orphaned native openssh mux 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_native_live_'.uniqid();
|
||||
$orphanSocket = $muxDir.'/mux_native_orphan_'.uniqid();
|
||||
File::put($liveSocket, 'x');
|
||||
|
||||
Process::fake([
|
||||
'ps*' => Process::result(output: "111 1 5000 ssh: {$liveSocket} [mux]\n".
|
||||
"222 1 5000 ssh: {$orphanSocket} [mux]\n"),
|
||||
'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'));
|
||||
|
||||
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\n".
|
||||
"200 100 5000 cloudflared access ssh --hostname host.example.com\n".
|
||||
"300 2176 5000 cloudflared access ssh --hostname host.example.com\n".
|
||||
"400 2176 30 cloudflared access ssh --hostname host.example.com\n".
|
||||
"2176 1 9000 /usr/bin/some-supervisor\n"),
|
||||
'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\n"),
|
||||
'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('resets duplicate ssh mux process groups atomically when reaping is enabled', function () {
|
||||
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
File::ensureDirectoryExists($muxDir);
|
||||
$controlPath = $muxDir.'/mux_duplicate_'.uniqid();
|
||||
File::put($controlPath, 'socket');
|
||||
|
||||
Process::fake([
|
||||
'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n".
|
||||
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n"),
|
||||
'kill*' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
$job = new CleanupStaleMultiplexedConnections;
|
||||
$method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
|
||||
expect(file_exists($controlPath))->toBeFalse();
|
||||
});
|
||||
|
||||
it('resets duplicate native openssh mux process groups atomically when reaping is enabled', function () {
|
||||
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
File::ensureDirectoryExists($muxDir);
|
||||
$controlPath = $muxDir.'/mux_native_duplicate_'.uniqid();
|
||||
File::put($controlPath, 'socket');
|
||||
|
||||
Process::fake([
|
||||
'ps*' => Process::result(output: "111 1 5000 ssh: {$controlPath} [mux]\n".
|
||||
"222 1 5000 ssh: {$controlPath} [mux]\n"),
|
||||
'kill*' => Process::result(exitCode: 0),
|
||||
]);
|
||||
|
||||
$job = new CleanupStaleMultiplexedConnections;
|
||||
$method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
|
||||
expect(file_exists($controlPath))->toBeFalse();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function createSyncBunnyFailingBinary(string $binDir, string $name): void
|
||||
{
|
||||
file_put_contents("{$binDir}/{$name}", <<<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s %s\n' "$(basename "$0")" "$*" >> "$SYNC_BUNNY_TEST_LOG"
|
||||
exit 1
|
||||
SH);
|
||||
chmod("{$binDir}/{$name}", 0755);
|
||||
}
|
||||
|
||||
it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () {
|
||||
Http::fake([
|
||||
'storage.bunnycdn.com/*' => Http::response([], 201),
|
||||
'api.bunny.net/purge*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
|
||||
$logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
|
||||
|
||||
mkdir($binDir, 0755, true);
|
||||
createSyncBunnyFailingBinary($binDir, 'gh');
|
||||
createSyncBunnyFailingBinary($binDir, 'git');
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$binDir}:{$originalPath}");
|
||||
putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
|
||||
|
||||
try {
|
||||
$this->artisan('sync:bunny --release --nightly')
|
||||
->expectsConfirmation('Are you sure you want to proceed?', 'yes')
|
||||
->expectsOutputToContain('BunnyCDN sync: ✓ Complete')
|
||||
->doesntExpectOutputToContain('GitHub PR')
|
||||
->assertExitCode(0);
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
putenv('SYNC_BUNNY_TEST_LOG');
|
||||
}
|
||||
|
||||
expect(file_exists($logFile))->toBeFalse();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json');
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
|
||||
&& $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('attaches the root user as owner when reusing an existing root team', function () {
|
||||
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
|
||||
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
|
||||
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
|
||||
});
|
||||
|
||||
it('promotes the root user to owner when the reused root team pivot already exists', function () {
|
||||
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
|
||||
|
||||
DB::table('team_user')->insert([
|
||||
'team_id' => 0,
|
||||
'user_id' => 0,
|
||||
'role' => 'member',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
|
||||
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Env;
|
||||
|
||||
function databaseConfigWithEnvironment(array $overrides): array
|
||||
{
|
||||
$keys = [
|
||||
'DB_HOST',
|
||||
'DB_READ_HOST',
|
||||
'DB_WRITE_HOST',
|
||||
];
|
||||
|
||||
$repository = Env::getRepository();
|
||||
$original = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$original[$key] = env($key);
|
||||
$repository->clear($key);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($overrides as $key => $value) {
|
||||
$repository->set($key, (string) $value);
|
||||
}
|
||||
|
||||
return require __DIR__.'/../../config/database.php';
|
||||
} finally {
|
||||
foreach ($keys as $key) {
|
||||
$repository->clear($key);
|
||||
|
||||
if ($original[$key] !== null) {
|
||||
$repository->set($key, (string) $original[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('trims and filters read hosts from comma separated values', function () {
|
||||
$config = databaseConfigWithEnvironment([
|
||||
'DB_READ_HOST' => ' read-1, read-2, ',
|
||||
]);
|
||||
|
||||
expect($config['connections']['pgsql']['read']['host'])->toBe(['read-1', 'read-2']);
|
||||
});
|
||||
|
||||
it('falls back to db host when write host is empty', function () {
|
||||
$config = databaseConfigWithEnvironment([
|
||||
'DB_HOST' => 'primary-db',
|
||||
'DB_READ_HOST' => 'read-db',
|
||||
'DB_WRITE_HOST' => '',
|
||||
]);
|
||||
|
||||
expect($config['connections']['pgsql']['write']['host'])->toBe(['primary-db']);
|
||||
});
|
||||
|
||||
it('falls back to the default host when write host and db host are empty', function () {
|
||||
$config = databaseConfigWithEnvironment([
|
||||
'DB_HOST' => '',
|
||||
'DB_READ_HOST' => 'read-db',
|
||||
'DB_WRITE_HOST' => '',
|
||||
]);
|
||||
|
||||
expect($config['connections']['pgsql']['write']['host'])->toBe(['coolify-db']);
|
||||
});
|
||||
@@ -93,8 +93,8 @@ it('detects environment variable value changes without exposing secret values',
|
||||
|
||||
expect($change)->not->toBeNull()
|
||||
->and($change['display_summary'])->toBe('Changed')
|
||||
->and($change['old_display_value'])->toBe('Set')
|
||||
->and($change['new_display_value'])->toBe('Set')
|
||||
->and($change['old_display_value'])->toBe('••••••••')
|
||||
->and($change['new_display_value'])->toBe('••••••••')
|
||||
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ it('describes added environment variables as set without exposing secret values'
|
||||
|
||||
expect($change)->not->toBeNull()
|
||||
->and($change['display_summary'])->toBeNull()
|
||||
->and($change['old_display_value'])->toBe('Not set')
|
||||
->and($change['new_display_value'])->toBe('Set')
|
||||
->and($change['old_display_value'])->toBe('-')
|
||||
->and($change['new_display_value'])->toBe('••••••••')
|
||||
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
test('S3Storage model has correct cast definitions', function () {
|
||||
$s3Storage = new S3Storage;
|
||||
@@ -45,9 +49,72 @@ test('S3Storage awsUrl method constructs correct URL format', function () {
|
||||
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
|
||||
});
|
||||
|
||||
test('S3Storage model is guarded correctly', function () {
|
||||
test('S3Storage model fillable attributes are configured correctly', function () {
|
||||
$s3Storage = new S3Storage;
|
||||
|
||||
// The model should have $guarded = [] which means everything is fillable
|
||||
expect($s3Storage->getGuarded())->toBe([]);
|
||||
expect($s3Storage->getFillable())->toBe([
|
||||
'name',
|
||||
'description',
|
||||
'region',
|
||||
'key',
|
||||
'secret',
|
||||
'bucket',
|
||||
'endpoint',
|
||||
'is_usable',
|
||||
'unusable_email_sent',
|
||||
]);
|
||||
});
|
||||
|
||||
test('S3Storage connection validation uses short s3 client timeouts', function () {
|
||||
$disk = Mockery::mock();
|
||||
$disk->expects('files')->once()->andReturn([]);
|
||||
|
||||
Storage::expects('build')
|
||||
->once()
|
||||
->with(Mockery::on(function (array $config) {
|
||||
expect($config['http']['connect_timeout'])->toBe(15);
|
||||
expect($config['http']['timeout'])->toBe(15);
|
||||
|
||||
return true;
|
||||
}))
|
||||
->andReturn($disk);
|
||||
|
||||
$s3Storage = new S3Storage;
|
||||
$s3Storage->setRawAttributes([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => null,
|
||||
'secret' => null,
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
]);
|
||||
|
||||
$s3Storage->testConnection();
|
||||
|
||||
expect($s3Storage->is_usable)->toBeTrue();
|
||||
});
|
||||
|
||||
test('S3Storage connection validation returns friendly timeout error', function () {
|
||||
$disk = Mockery::mock();
|
||||
$disk->expects('files')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds'));
|
||||
|
||||
Storage::expects('build')->once()->andReturn($disk);
|
||||
|
||||
$s3Storage = new S3Storage;
|
||||
$s3Storage->setRawAttributes([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => null,
|
||||
'secret' => null,
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
'unusable_email_sent' => true,
|
||||
]);
|
||||
|
||||
expect(fn () => $s3Storage->testConnection())
|
||||
->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.');
|
||||
|
||||
expect($s3Storage->is_usable)->toBeFalse();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Rules\ValidServerIp;
|
||||
|
||||
@@ -57,20 +58,20 @@ it('rejects injection payloads in server ip', function (string $payload) {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('strips dangerous characters from server ip on write', function () {
|
||||
$server = new App\Models\Server;
|
||||
$server = new Server;
|
||||
$server->ip = '192.168.1.1;rm -rf /';
|
||||
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
|
||||
expect($server->ip)->toBe('192.168.1.1rm-rf');
|
||||
});
|
||||
|
||||
it('strips dangerous characters from server user on write', function () {
|
||||
$server = new App\Models\Server;
|
||||
$server = new Server;
|
||||
$server->user = 'root$(id)';
|
||||
expect($server->user)->toBe('rootid');
|
||||
});
|
||||
|
||||
it('strips non-numeric characters from server port on write', function () {
|
||||
$server = new App\Models\Server;
|
||||
$server = new Server;
|
||||
$server->port = '22; evil';
|
||||
expect($server->port)->toBe(22);
|
||||
});
|
||||
@@ -102,6 +103,17 @@ it('has no raw user@ip string interpolation in SshMultiplexingHelper', function
|
||||
expect($source)->not->toContain('{$server->user}@{$server->ip}');
|
||||
});
|
||||
|
||||
it('escapes scp source and destination operands', function () {
|
||||
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
|
||||
expect($source)
|
||||
->toContain('escapeshellarg($source)')
|
||||
->toContain('escapeshellarg($dest)')
|
||||
->not->toContain('"{$source} "')
|
||||
->not->toContain('":{$dest}"');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ValidHostname rejects shell metacharacters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user