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

This commit is contained in:
Julien Bouquillon authored and GitHub committed 2026-05-28 10:26:02 +02:00
commit a0d3da372e
291 files changed
+17233 -6540

No files matched your search

+344
View File
@@ -0,0 +1,344 @@
<?php
use App\Models\Application;
use App\Models\Environment;
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;
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' => 'railpack-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::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function railpackApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function makeRailpackApp(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' => 'railpack',
], $overrides));
}
describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () {
test('rejects unsupported build_pack at controller layer', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'totally-bogus',
]);
$response->assertStatus(422);
});
test('switching from dockerfile to railpack clears dockerfile fields', function () {
$app = makeRailpackApp([
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:20',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'railpack',
]);
$response->assertOk();
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->dockerfile)->toBeNull();
expect($app->dockerfile_location)->toBeNull();
expect($app->dockerfile_target_build)->toBeNull();
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
});
test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () {
$app = makeRailpackApp([
'build_pack' => 'dockercompose',
'docker_compose_domains' => '{"app": "example.com"}',
'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx",
]);
$app->environment_variables()->createMany([
['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false],
['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false],
['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false],
]);
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'railpack',
]);
$response->assertOk();
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->docker_compose_domains)->toBeNull();
expect($app->docker_compose_raw)->toBeNull();
expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('install/build/start commands persist for railpack apps', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$response->assertOk();
$app->refresh();
expect($app->install_command)->toBe('npm ci');
expect($app->build_command)->toBe('npm run build');
expect($app->start_command)->toBe('node server.js');
});
});
describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () {
test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
]);
$response->assertCreated();
$app->refresh();
expect($app->railpack_environment_variables)->toHaveCount(1);
expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION');
expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0);
});
test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => true,
'is_buildtime' => false,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$runtime = $app->runtime_environment_variables;
expect($runtime->pluck('key')->all())->toBe(['APP_ENV']);
expect($app->railpack_environment_variables)->toHaveCount(0);
});
test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_BUILD_CMD',
'value' => 'npm run build',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => true,
])->assertCreated();
$app->refresh();
expect($app->railpack_environment_variables_preview)->toHaveCount(1);
expect($app->railpack_environment_variables)->toHaveCount(0);
});
test('buildtime-only env has is_buildtime=true and is_runtime=false', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'API_KEY',
'value' => 'sekret',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'API_KEY')->first();
expect($env)->not->toBeNull();
expect((bool) $env->is_buildtime)->toBeTrue();
expect((bool) $env->is_runtime)->toBeFalse();
// Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var)
// but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone.
expect($env->resourceable_id)->toBe($app->id);
});
test('runtime-only env has is_runtime=true and is_buildtime=false', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'LOG_LEVEL',
'value' => 'debug',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first();
expect((bool) $env->is_buildtime)->toBeFalse();
expect((bool) $env->is_runtime)->toBeTrue();
});
test('railpack build variables collection includes only is_buildtime=true entries', function () {
// Sanity check the underlying query used by the deploy job: railpack_build_variables()
// pulls $application->environment_variables()->where('is_buildtime', true)->get()
// (see ApplicationDeploymentJob::railpack_build_variables).
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'BUILD_ARG',
'value' => 'in-build',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RUNTIME_ARG',
'value' => 'in-runtime',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all();
expect($buildtime)->toContain('BUILD_ARG');
expect($buildtime)->not->toContain('RUNTIME_ARG');
});
test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () {
// Documents generate_coolify_env_variables() override behavior:
// it skips generation when application->environment_variables already has the key.
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'COOLIFY_FQDN',
'value' => 'overridden.example.com',
'is_buildtime' => true,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first();
expect($env)->not->toBeNull();
expect($env->value)->toBe('overridden.example.com');
// Confirm the model relation used by override-skip logic finds it
expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse();
});
test('is_literal flag persists on create', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_LITERAL_FLAG',
'value' => '$NOT_INTERPOLATED',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
'is_literal' => true,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first();
expect((bool) $env->is_literal)->toBeTrue();
});
test('PATCH env updates buildtime/runtime flags', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'TOGGLE_VAR',
'value' => 'v1',
'is_buildtime' => true,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'TOGGLE_VAR',
'value' => 'v2',
'is_buildtime' => false,
'is_runtime' => true,
'is_multiline' => false,
'is_shown_once' => false,
])->assertStatus(201);
$app->refresh();
$env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first();
expect($env->value)->toBe('v2');
expect((bool) $env->is_buildtime)->toBeFalse();
expect((bool) $env->is_runtime)->toBeTrue();
});
});
@@ -6,6 +6,7 @@ use App\Models\Team;
use App\Models\User;
use App\Notifications\ApiTokenExpiringNotification;
use Carbon\Carbon;
use Illuminate\Contracts\Notifications\Dispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
@@ -29,11 +30,12 @@ beforeEach(function () {
Notification::fake();
});
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
{
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
$token = $plain->accessToken;
$token->team_id = $team->id;
$token->api_token_expiration_warning_sent_at = $warningSentAt;
$token->save();
return $token->fresh();
@@ -41,14 +43,30 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
describe('ApiTokenExpirationWarningJob', function () {
test('notifies team when token expires within 24h', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(23));
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
});
test('rate limiter prevents duplicate warnings on repeat runs', function () {
test('does not mark token as warned when notification fails', function () {
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
$dispatcher = Mockery::mock(Dispatcher::class);
$dispatcher->shouldReceive('send')
->once()
->andThrow(new RuntimeException('Notification failed'));
$this->app->instance(Dispatcher::class, $dispatcher);
expect(fn () => (new ApiTokenExpirationWarningJob)->handle())
->toThrow(RuntimeException::class, 'Notification failed');
expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
});
test('database marker prevents duplicate warnings on repeat runs', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
(new ApiTokenExpirationWarningJob)->handle();
@@ -57,6 +75,35 @@ describe('ApiTokenExpirationWarningJob', function () {
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
});
test('database marker prevents duplicate warnings after cache is flushed', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
(new ApiTokenExpirationWarningJob)->handle();
Cache::flush();
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
});
test('skips tokens that already have an expiration warning marker', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
test('notifies once for each unmarked expiring token', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
createTokenExpiring($this->user, $this->team, now()->addHours(23));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
});
test('skips tokens expiring more than 24h out', function () {
createTokenExpiring($this->user, $this->team, now()->addDays(3));
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Attributes\Locked;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
});
test('api token permission flags are locked', function (string $property) {
$property = new ReflectionProperty(ApiTokens::class, $property);
expect($property->getAttributes(Locked::class))->not->toBeEmpty();
})->with([
'root permission flag' => 'canUseRootPermissions',
'write permission flag' => 'canUseWritePermissions',
]);
test('member cannot tamper with root permission flag', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('canUseRootPermissions', true);
})->throws(CannotUpdateLockedPropertyException::class);
test('member cannot create root token through tampered permissions payload', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'pwned-root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->call('addNewToken');
expect($member->tokens()->count())->toBe(0);
});
test('member can still create read token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'read-token')
->set('expiresInDays', 30)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
$token = $member->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->abilities)->toBe(['read']);
});
test('owner can create root token', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->call('addNewToken')
->assertHasNoErrors();
$token = $owner->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->abilities)->toBe(['root']);
});
@@ -78,26 +78,29 @@ describe('Application Model Buildpack Cleanup', function () {
// Add environment variables that should be deleted
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_FQDN_APP',
'value' => 'app.example.com',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_URL_APP',
'value' => 'http://app.example.com',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'should_remain',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
@@ -117,6 +120,87 @@ describe('Application Model Buildpack Cleanup', function () {
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
$application->build_pack = 'railpack';
$application->save();
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
});
test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockercompose',
'docker_compose_domains' => '{"app": "example.com"}',
'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx',
]);
// Add environment variables that should be deleted
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_FQDN_APP',
'value' => 'app.example.com',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_URL_APP',
'value' => 'http://app.example.com',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'should_remain',
'is_buildtime' => false,
'is_preview' => false,
]);
$application->build_pack = 'railpack';
$application->save();
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->docker_compose_domains)->toBeNull();
expect($application->docker_compose_raw)->toBeNull();
// Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted
expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
// Verify regular variables remain
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('model does not clear dockerfile fields when switching to dockerfile', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
@@ -156,6 +240,27 @@ describe('Application Model Buildpack Cleanup', function () {
expect($application->dockerfile)->toBeNull();
});
test('dockerfile location defaults only for dockerfile buildpack', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$nixpacksApplication = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'nixpacks',
'dockerfile_location' => null,
]);
$dockerfileApplication = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockerfile',
'dockerfile_location' => null,
]);
expect($nixpacksApplication->refresh()->dockerfile_location)->toBeNull();
expect($dockerfileApplication->refresh()->dockerfile_location)->toBe('/Dockerfile');
});
test('model does not trigger cleanup when build_pack is not changed', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
@@ -0,0 +1,101 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function configurationChangedTestApplication(array $attributes = []): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create(array_merge([
'environment_id' => $environment->id,
'status' => 'running:healthy',
'build_command' => 'npm run build',
], $attributes));
}
function configurationChangedDeployment(Application $application): ApplicationDeploymentQueue
{
return ApplicationDeploymentQueue::create([
'application_id' => (string) $application->id,
'deployment_uuid' => (string) Str::uuid(),
'status' => 'finished',
'commit' => 'HEAD',
]);
}
it('stores deployment configuration snapshot and clears pending changes', function () {
$application = configurationChangedTestApplication();
$deployment = configurationChangedDeployment($application);
$application->markDeploymentConfigurationApplied($deployment);
expect($deployment->refresh()->configuration_hash)->not->toBeNull()
->and($deployment->configuration_snapshot)->toBeArray()
->and($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
});
it('stores a diff between successful deployments', function () {
$application = configurationChangedTestApplication();
$firstDeployment = configurationChangedDeployment($application);
$application->markDeploymentConfigurationApplied($firstDeployment);
$application->update(['build_command' => 'pnpm build']);
$secondDeployment = configurationChangedDeployment($application->refresh());
$application->markDeploymentConfigurationApplied($secondDeployment);
expect($secondDeployment->refresh()->configuration_diff['count'])->toBe(1)
->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
});
it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
$application = configurationChangedTestApplication();
EnvironmentVariable::create([
'key' => 'APP_ENV',
'value' => 'preview',
'is_preview' => true,
'is_multiline' => false,
'is_literal' => false,
'is_buildtime' => true,
'is_runtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$application->forceFill([
'config_hash' => 'legacy-hash',
'pull_request_id' => 123,
]);
$diff = $application->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->count())->toBeGreaterThan(0);
});
it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () {
$application = configurationChangedTestApplication();
$application->isConfigurationChanged(save: true);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
$application->update(['build_command' => 'pnpm build']);
$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');
});
@@ -0,0 +1,150 @@
<?php
use App\Models\Application;
use App\Models\Environment;
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\Facades\DB;
use Illuminate\Support\Str;
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' => 'custom-nginx-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::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function customNginxApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function customNginxConfig(): string
{
return <<<'NGINX'
server {
listen 80;
location / {
try_files $uri $uri/ /index.html;
}
}
NGINX;
}
function makeCustomNginxApplication(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' => 'static',
], $overrides));
}
describe('PATCH /api/v1/applications/{uuid} custom_nginx_configuration', function () {
test('decodes base64 custom nginx configuration before storing it', function () {
$application = makeCustomNginxApplication();
$configuration = customNginxConfig();
$encodedConfiguration = base64_encode($configuration);
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'custom_nginx_configuration' => $encodedConfiguration,
]);
$response->assertOk();
$application->refresh();
expect($application->custom_nginx_configuration)->toBe($configuration);
$storedConfiguration = DB::table('applications')
->where('id', $application->id)
->value('custom_nginx_configuration');
expect($storedConfiguration)->toBe(base64_encode($configuration));
$this->withHeaders(customNginxApiHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}")
->assertOk()
->assertJsonPath('custom_nginx_configuration', $configuration);
});
test('rejects custom nginx configuration that is not base64 encoded', function () {
$application = makeCustomNginxApplication();
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'custom_nginx_configuration' => customNginxConfig(),
]);
$response->assertUnprocessable()
->assertJsonPath('errors.custom_nginx_configuration', 'The custom_nginx_configuration should be base64 encoded.');
});
test('can clear custom nginx configuration with null', function () {
$application = makeCustomNginxApplication([
'custom_nginx_configuration' => customNginxConfig(),
]);
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'custom_nginx_configuration' => null,
]);
$response->assertOk();
$application->refresh();
expect($application->custom_nginx_configuration)->toBeNull();
});
});
describe('POST /api/v1/applications/public custom_nginx_configuration', function () {
test('decodes base64 custom nginx configuration before storing it on create', function () {
$configuration = customNginxConfig();
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
'git_branch' => 'main',
'build_pack' => 'static',
'ports_exposes' => '80',
'custom_nginx_configuration' => base64_encode($configuration),
'autogenerate_domain' => false,
]);
$response->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->custom_nginx_configuration)->toBe($configuration);
});
});
@@ -0,0 +1,388 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
uses(RefreshDatabase::class);
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public ?string $writtenDockerfile = null;
public function __construct() {}
public function execute_remote_command(...$commands)
{
$this->recordedCommands[] = $commands;
foreach ($commands as $command) {
$commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command;
if (! is_string($commandString)) {
continue;
}
if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) {
$this->writtenDockerfile = base64_decode($matches[1]) ?: null;
}
}
}
}
function makeDeploymentControlVarFixture(array $applicationAttributes = []): array
{
$team = Team::create([
'name' => 'Control Var Team',
'description' => 'Team for deployment control var tests.',
'personal_team' => false,
'show_boarding' => false,
]);
$project = Project::create([
'name' => 'Control Var Project',
'team_id' => $team->id,
]);
$environment = Environment::where('project_id', $project->id)->firstOrFail();
$server = Server::factory()->create([
'team_id' => $team->id,
]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockerfile',
...$applicationAttributes,
]);
$application->settings()->update([
'inject_build_args_to_dockerfile' => true,
'include_source_commit_in_build' => false,
'is_env_sorting_enabled' => false,
]);
return [$application->fresh(), $server];
}
function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable
{
return EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => true,
'is_multiline' => false,
'is_literal' => false,
...$attributes,
]);
}
function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array
{
$job = new TestableControlVarFilteringDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
$queue->shouldReceive('addLogEntry')->andReturnNull();
$properties = [
'application' => $application->fresh(),
'application_deployment_queue' => $queue,
'build_pack' => $application->build_pack,
'mainServer' => $server,
'pull_request_id' => 0,
'commit' => 'HEAD',
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'dockerfile_location' => '/Dockerfile',
'container_name' => 'control-var-app',
'coolify_variables' => null,
'dockerSecretsSupported' => false,
];
$mergedProperties = array_merge($properties, $overrides);
$mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []);
if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) {
$mergedProperties['preview'] = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => $mergedProperties['pull_request_id'],
'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'],
'fqdn' => 'https://preview.example.com',
]);
}
foreach ($mergedProperties as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection];
}
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invoke($job);
}
function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
{
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
return $reflectionProperty->getValue($job);
}
it('filters buildpack control vars from generic build args', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
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('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse();
});
it('filters buildpack control vars from preview build-time env files', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_preview' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_preview' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_preview' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 42,
]);
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue();
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
});
it('filters buildpack control vars from preview runtime env fallback', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_NAME',
'value' => 'coolify',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_FLAG',
'value' => 'enabled',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
$application->environment_variables_preview()
->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION'])
->delete();
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 99,
]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
});
it('filters buildpack control vars from dockerfile arg injection', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'saved_outputs' => [
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
],
]);
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
});
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
'fqdn' => 'https://railpack.example.com',
'install_command' => 'pnpm install --frozen-lockfile',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RUNTIME_ONLY',
'value' => 'runtime',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'main',
]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect($variables->get('APP_ENV'))->toBe('production');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
});
it('builds preview railpack variables without leaking stale nixpacks vars', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
'fqdn' => 'https://railpack.example.com',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_BUILD_FLAG',
'value' => 'enabled',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_RUNTIME_ONLY',
'value' => 'runtime',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'feature/railpack',
'pull_request_id' => 123,
]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
});
@@ -0,0 +1,85 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->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' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('existing application buildpack selector lists nixpacks before railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSeeInOrder([
'<option value="nixpacks">Nixpacks</option>',
'<option value="railpack">Railpack (Beta)</option>',
], false);
});
test('existing application shows railpack beta label in build pack selector', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'railpack',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('Railpack (Beta)');
});
@@ -0,0 +1,126 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
function makePreviewImageNameJob(string $commit, int $pullRequestId = 42, ?string $registryImageName = null, string $deploymentUuid = 'deployment-uuid'): object
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$application = new Application;
$application->uuid = 'preview-app';
$application->build_pack = 'dockerfile';
$application->dockerfile = null;
$application->docker_registry_image_name = $registryImageName;
foreach ([
'application' => $application,
'pull_request_id' => $pullRequestId,
'commit' => $commit,
'deployment_uuid' => $deploymentUuid,
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return $job;
}
function generatePreviewImageNames(object $job): array
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $reflection->getMethod('generate_image_names');
$method->setAccessible(true);
$method->invoke($job);
$buildImageName = $reflection->getProperty('build_image_name');
$buildImageName->setAccessible(true);
$productionImageName = $reflection->getProperty('production_image_name');
$productionImageName->setAccessible(true);
return [
'build' => $buildImageName->getValue($job),
'production' => $productionImageName->getValue($job),
];
}
it('includes the pull request id and commit in preview image names', function () {
$names = generatePreviewImageNames(makePreviewImageNameJob(
commit: '111222333444555666777888999000aaabbbccc1',
pullRequestId: 123,
));
expect($names['production'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1')
->and($names['build'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1-build');
});
it('generates different preview image names for different commits on the same pull request', function () {
$firstCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
pullRequestId: 123,
));
$secondCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
commit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
pullRequestId: 123,
));
expect($firstCommitNames['production'])->not->toBe($secondCommitNames['production'])
->and($firstCommitNames['build'])->not->toBe($secondCommitNames['build']);
});
it('uses the deployment uuid for preview image names when commit is HEAD', function () {
$firstDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
commit: 'HEAD',
pullRequestId: 123,
deploymentUuid: 'deployment-one',
));
$secondDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
commit: 'HEAD',
pullRequestId: 123,
deploymentUuid: 'deployment-two',
));
expect($firstDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-one')
->and($firstDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-one-build')
->and($secondDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-two')
->and($secondDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-two-build');
});
it('uses the configured registry image name for commit-specific preview tags', function () {
$names = generatePreviewImageNames(makePreviewImageNameJob(
commit: '111222333444555666777888999000aaabbbccc1',
pullRequestId: 123,
registryImageName: 'registry.example.com/team/app',
));
expect($names['production'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1')
->and($names['build'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1-build');
});
it('sanitizes and truncates preview image tags to docker tag limits', function () {
$names = generatePreviewImageNames(makePreviewImageNameJob(
commit: str_repeat('feature/add dockerfile changes/', 10),
pullRequestId: 123,
));
$productionTag = str($names['production'])->after(':')->toString();
$buildTag = str($names['build'])->after(':')->toString();
expect(strlen($productionTag))->toBeLessThanOrEqual(128)
->and(strlen($buildTag))->toBeLessThanOrEqual(128)
->and($productionTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+$/')
->and($buildTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+-build$/');
});
it('keeps non-preview dockerfile image names commit based', function () {
$names = generatePreviewImageNames(makePreviewImageNameJob(
commit: '111222333444555666777888999000aaabbbccc1',
pullRequestId: 0,
));
expect($names['production'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1')
->and($names['build'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1-build');
});
+150
View File
@@ -0,0 +1,150 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('Application Railpack Support', function () {
beforeEach(function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
test('could_set_build_commands returns true for railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
expect($application->could_set_build_commands())->toBeTrue();
});
test('could_set_build_commands returns true for nixpacks', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'nixpacks',
]);
expect($application->could_set_build_commands())->toBeTrue();
});
test('could_set_build_commands returns false for dockerfile', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
expect($application->could_set_build_commands())->toBeFalse();
});
test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'value',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_preview' => false,
]);
$railpackVars = $application->railpack_environment_variables;
expect($railpackVars)->toHaveCount(1);
expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION');
});
test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'APP_ENV',
'value' => 'production',
'is_buildtime' => false,
'is_preview' => false,
]);
$runtimeVars = $application->runtime_environment_variables;
expect($runtimeVars)->toHaveCount(1);
expect($runtimeVars->first()->key)->toBe('APP_ENV');
});
test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_BUILD_CMD',
'value' => 'npm run build',
'is_buildtime' => true,
'is_preview' => true,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'value',
'is_buildtime' => false,
'is_preview' => true,
]);
$previewVars = $application->railpack_environment_variables_preview;
expect($previewVars)->toHaveCount(1);
expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD');
});
});
+51
View File
@@ -0,0 +1,51 @@
<?php
use App\Models\Application;
use Database\Seeders\ApplicationSeeder;
use Database\Seeders\GithubAppSeeder;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ServerSeeder;
use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('seeds a railpack nodejs fastify example alongside the existing nixpacks example', function () {
$this->seed([
UserSeeder::class,
TeamSeeder::class,
PrivateKeySeeder::class,
ServerSeeder::class,
ProjectSeeder::class,
StandaloneDockerSeeder::class,
GithubAppSeeder::class,
ApplicationSeeder::class,
]);
$nixpacksExample = Application::where('uuid', 'nodejs')->first();
$railpackExample = Application::where('uuid', 'railpack-nodejs')->first();
expect($nixpacksExample)
->not->toBeNull()
->and($nixpacksExample->name)->toBe('NodeJS Fastify Example')
->and($nixpacksExample->build_pack)->toBe('nixpacks')
->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);
});
@@ -0,0 +1,127 @@
<?php
use App\Livewire\Project\Application\Source;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
/**
* Create a PrivateKey without firing model events. The PrivateKey `saving`
* hook validates/fingerprints real key material and the `saved` hook writes
* to the filesystem — neither is wanted in a unit test. Skipping events also
* skips BaseModel's uuid generation, so the uuid is set explicitly here (it
* is not in $fillable, so it cannot go through mass assignment).
*/
function makePrivateKey(string $name, string $material, string $fingerprint, int $teamId): PrivateKey
{
return PrivateKey::withoutEvents(function () use ($name, $material, $fingerprint, $teamId) {
$key = new PrivateKey([
'name' => $name,
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n{$material}\n-----END OPENSSH PRIVATE KEY-----",
'fingerprint' => $fingerprint,
'team_id' => $teamId,
]);
$key->uuid = (string) new Cuid2;
$key->save();
return $key;
});
}
beforeEach(function () {
// handleError() turns a ModelNotFoundException into abort(404); rendering the 404
// page reads InstanceSettings::get(), which findOrFail(0)s. Seed the singleton row.
// `id` is not in $fillable, so it must be set outside of mass assignment.
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
// Team A — the attacker
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->teamA->members()->attach($this->userA->id, ['role' => 'owner']);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'private_key_id' => null,
'source_id' => null,
'source_type' => null,
]);
// Team B — the victim (holds the secrets we are trying to steal)
$this->teamB = Team::factory()->create();
$this->victimPrivateKey = makePrivateKey('victim-ssh-key', 'VICTIM_KEY_MATERIAL', 'victim-fingerprint', $this->teamB->id);
$this->victimGithubApp = GithubApp::create([
'name' => 'victim-github-app',
'team_id' => $this->teamB->id,
'private_key_id' => $this->victimPrivateKey->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('setPrivateKey rejects a PrivateKey owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('setPrivateKey', $this->victimPrivateKey->id);
$this->applicationA->refresh();
expect($this->applicationA->private_key_id)->not->toBe($this->victimPrivateKey->id);
expect($this->applicationA->private_key_id)->toBeNull();
});
test('setPrivateKey accepts a PrivateKey owned by the current team', function () {
$ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id);
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('setPrivateKey', $ownKey->id);
$this->applicationA->refresh();
expect($this->applicationA->private_key_id)->toBe($ownKey->id);
});
test('changeSource rejects a GithubApp owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('changeSource', $this->victimGithubApp->id, GithubApp::class);
$this->applicationA->refresh();
expect($this->applicationA->source_id)->not->toBe($this->victimGithubApp->id);
expect($this->applicationA->source_type)->not->toBe(GithubApp::class);
});
test('changeSource rejects an arbitrary class as source_type', function () {
Livewire::test(Source::class, ['application' => $this->applicationA])
->call('changeSource', $this->victimGithubApp->id, Server::class);
$this->applicationA->refresh();
expect($this->applicationA->source_type)->not->toBe(Server::class);
});
test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () {
// Without #[Locked], an attacker could POST {"updates": {"privateKeyId": <foreign_id>},
// "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through
// Application::update(['private_key_id' => $this->privateKeyId]) — bypassing setPrivateKey()
// and its team-scoped lookup entirely. Locking the property closes that path at the wire layer.
Livewire::test(Source::class, ['application' => $this->applicationA])
->set('privateKeyId', $this->victimPrivateKey->id);
})->throws(CannotUpdateLockedPropertyException::class);
@@ -24,12 +24,23 @@ beforeEach(function () {
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function applicationSourceValidPrivateKey(): string
{
return '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----';
}
describe('Application Source with localhost key (id=0)', function () {
test('renders deploy key section when private_key_id is 0', function () {
$privateKey = PrivateKey::create([
'id' => 0,
'name' => 'localhost',
'private_key' => 'test-key-content',
'private_key' => applicationSourceValidPrivateKey(),
'team_id' => $this->team->id,
]);
@@ -56,4 +67,19 @@ describe('Application Source with localhost key (id=0)', function () {
->assertDontSee('Deploy Key')
->assertSee('No source connected');
});
test('dispatches configuration changed when source settings are saved', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
]);
Livewire::test(Source::class, ['application' => $application])
->set('gitBranch', 'next')
->call('submit')
->assertHasNoErrors()
->assertDispatched('configurationChanged');
});
});
@@ -111,6 +111,29 @@ describe('Buildpack Switching Cleanup', function () {
expect($application->dockerfile)->toBeNull();
});
test('clears dockerfile fields when switching from dockerfile to railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('buildPack', 'railpack')
->call('updatedBuildPack');
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
});
test('clears dockerfile fields when switching from dockerfile to dockercompose', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
@@ -0,0 +1,232 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Livewire\Project\Shared\Destination;
use App\Models\Application;
use App\Models\Environment;
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\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'name' => 'dest-a-'.fake()->unique()->word(),
'network' => 'coolify-a-'.fake()->unique()->word(),
]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
]);
// A second usable destination on Team A's own server, used for positive-path tests.
$this->serverA2 = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA2 = StandaloneDocker::factory()->create([
'server_id' => $this->serverA2->id,
'name' => 'dest-a2-'.fake()->unique()->word(),
'network' => 'coolify-a2-'.fake()->unique()->word(),
]);
// Victim: Team B
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'name' => 'dest-b-'.fake()->unique()->word(),
'network' => 'coolify-b-'.fake()->unique()->word(),
]);
// Act as attacker (Team A)
$this->actingAs($this->userA);
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 {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
// handleError on ModelNotFoundException calls abort(404); pivot assertion is source of truth.
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
expect($this->applicationA->fresh()->additional_servers)->toHaveCount(0);
});
test('cannot attach own network paired with another team\'s server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverB->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('cannot attach another team\'s network paired with own server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationB->id, $this->serverA2->id);
} catch (Throwable $e) {
}
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);
$additional = $this->applicationA->fresh()->additional_networks;
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA2->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id);
});
});
describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
test('cannot promote another team\'s network as the application\'s main destination', function () {
$originalDestinationId = $this->applicationA->destination_id;
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationB->id, $this->serverB->id);
} catch (Throwable $e) {
}
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();
});
});
@@ -0,0 +1,74 @@
<?php
/*
* Verifies the opt-in read/write replica split in config/database.php.
* The config file is re-required under different putenv() states so the
* env() calls re-evaluate, then the resulting pgsql array shape is asserted.
*/
function loadDbConfig(): array
{
return require base_path('config/database.php');
}
afterEach(function () {
foreach ([
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
'DB_STICKY',
] as $key) {
putenv($key);
}
});
it('has no replica keys when DB_READ_HOST is unset', function () {
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql)
->not->toHaveKey('read')
->not->toHaveKey('write')
->not->toHaveKey('sticky')
->and($pgsql['driver'])->toBe('pgsql');
});
it('enables the read/write split when DB_READ_HOST is set', function () {
putenv('DB_READ_HOST=replica1, replica2');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql)
->toHaveKey('read')
->toHaveKey('write')
->and($pgsql['read']['host'])->toBe(['replica1', 'replica2'])
->and($pgsql['sticky'])->toBeTrue();
});
it('falls back to DB_* values for unset replica options', function () {
putenv('DB_READ_HOST=replica1');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['read']['port'])->toBe(env('DB_PORT', '5432'))
->and($pgsql['read']['username'])->toBe(env('DB_USERNAME', 'coolify'))
->and($pgsql['write']['host'])->toBe([env('DB_HOST', 'coolify-db')]);
});
it('respects discrete replica overrides', function () {
putenv('DB_READ_HOST=replica1');
putenv('DB_READ_PORT=6432');
putenv('DB_READ_USERNAME=reader');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['read']['port'])->toBe('6432')
->and($pgsql['read']['username'])->toBe('reader');
});
it('disables sticky reads when DB_STICKY is false', function () {
putenv('DB_READ_HOST=replica1');
putenv('DB_STICKY=false');
$pgsql = loadDbConfig()['connections']['pgsql'];
expect($pgsql['sticky'])->toBeFalse();
});
@@ -0,0 +1,88 @@
<?php
use App\Livewire\Project\DeleteEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
// Current team
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
// Another team
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('mount cannot load DeleteEnvironment with environment from another team', function () {
Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentB->id]);
})->throws(ModelNotFoundException::class);
test('mount can load DeleteEnvironment with own team environment', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
expect($component->get('environmentName'))->toBe($this->environmentA->name);
});
test('environment_id is locked and cannot be reassigned from the client', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
try {
$component->set('environment_id', $this->environmentB->id);
$this->fail('Setting a #[Locked] property should have thrown.');
} catch (CannotUpdateLockedPropertyException) {
expect(true)->toBeTrue();
}
});
test('delete still removes an empty environment owned by the current team', function () {
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id])
->set('parameters', ['project_uuid' => $this->projectA->uuid]);
$component->call('delete');
expect(Environment::find($this->environmentA->id))->toBeNull();
});
test('delete cannot resolve a non-empty environment from another team', function () {
// The team-scoped lookup must stay in the delete() path so the
// "has defined resources" branch can never run for an environment
// outside the caller's team.
Application::factory()->create([
'environment_id' => $this->environmentB->id,
]);
$teamScopedLookup = fn () => Environment::ownedByCurrentTeam()
->findOrFail($this->environmentB->id);
expect($teamScopedLookup)->toThrow(ModelNotFoundException::class);
});
test('team scoped lookup permits own team environment', function () {
// Positive case so the cross-team check above cannot pass merely
// because the helper itself is broken.
$found = Environment::ownedByCurrentTeam()->findOrFail($this->environmentA->id);
expect($found->id)->toBe($this->environmentA->id);
});
+104
View File
@@ -0,0 +1,104 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
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\Testing\TestResponse;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
InstanceSettings::unguarded(function () {
InstanceSettings::query()->create([
'id' => 0,
'is_registration_enabled' => true,
]);
});
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
});
function showDeployment(string $status): TestResponse
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => test()->application->id,
'deployment_uuid' => 'deploy-scroll-'.$status,
'server_id' => test()->server->id,
'status' => $status,
'logs' => json_encode([[
'command' => null,
'output' => 'log line for '.$status,
'type' => 'stdout',
'timestamp' => now()->toISOString(),
'hidden' => false,
'batch' => 1,
'order' => 1,
]], JSON_THROW_ON_ERROR),
]);
return test()->get(route('project.application.deployment.show', [
'project_uuid' => test()->project->uuid,
'environment_uuid' => test()->environment->uuid,
'application_uuid' => test()->application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
]));
}
it('does not enable follow mode for a finished deployment', function () {
$response = showDeployment(ApplicationDeploymentStatus::FINISHED->value);
$response->assertSuccessful();
$response->assertSee('alwaysScroll: false', false);
$response->assertDontSee('alwaysScroll: true', false);
});
it('enables follow mode for an in-progress deployment', function () {
$response = showDeployment(ApplicationDeploymentStatus::IN_PROGRESS->value);
$response->assertSuccessful();
$response->assertSee('alwaysScroll: true', false);
});
it('scopes scroll teardown to the component so a stale loop cannot leak across deployments', function () {
$content = showDeployment(ApplicationDeploymentStatus::FINISHED->value)->getContent();
// Alpine destroy() tears the scroll loop down on wire:navigate away.
expect($content)->toContain('destroy()')
->toContain('cancelScrollLoop()')
// Container lookup is component-scoped, not a global getElementById.
->toContain("this.\$root.querySelector('#logsContainer')")
->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');
});
@@ -0,0 +1,18 @@
<?php
it('positions the deployments indicator from the sidebar collapsed state', function () {
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
$layoutView = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($indicatorView)
->toContain('transition-[left] duration-200')
->toContain(":class=\"collapsed ? 'lg:left-16' : 'lg:left-56'\"")
->not->toContain('fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4');
expect($layoutView)
->toContain('<div x-data="{')
->toContain('<livewire:deployments-indicator />');
expect(strpos($layoutView, '<div x-data="{'))
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
});
@@ -0,0 +1,22 @@
<?php
use App\Http\Controllers\Api\ServicesController;
use Illuminate\Support\Facades\Route;
test('deprecated docker compose application endpoint is not registered', function () {
$routes = collect(Route::getRoutes()->getRoutes())
->filter(fn ($route) => in_array('POST', $route->methods(), true))
->filter(fn ($route) => $route->uri() === 'api/v1/applications/dockercompose');
expect($routes)->toBeEmpty();
$this->postJson('/api/v1/applications/dockercompose')->assertNotFound();
});
test('custom docker compose services endpoint remains registered', function () {
$route = collect(Route::getRoutes()->getRoutes())
->first(fn ($route) => in_array('POST', $route->methods(), true) && $route->uri() === 'api/v1/services');
expect($route)->not->toBeNull()
->and($route->getActionName())->toBe(ServicesController::class.'@create_service');
});
@@ -0,0 +1,141 @@
<?php
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Database\Seeders\DevelopmentRailpackExamplesSeeder;
use Database\Seeders\GithubAppSeeder;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ServerSeeder;
use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function seedRailpackExamplePrerequisites(): void
{
test()->seed([
UserSeeder::class,
TeamSeeder::class,
PrivateKeySeeder::class,
ServerSeeder::class,
ProjectSeeder::class,
StandaloneDockerSeeder::class,
GithubAppSeeder::class,
]);
}
it('can seed the railpack examples directly on a clean development database', function () {
config()->set('app.env', 'local');
$this->seed(DevelopmentRailpackExamplesSeeder::class);
expect(Team::query()->find(0))->not->toBeNull();
expect(PrivateKey::query()->find(1))->not->toBeNull();
expect(Server::query()->find(0))->not->toBeNull();
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
expect(GithubApp::query()->find(0))->not->toBeNull();
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
it('seeds the railpack examples in development mode', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
expect($project)
->not->toBeNull()
->and($project->name)->toBe('Railpack Examples')
->and($project->environments)->toHaveCount(1)
->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID);
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
$nestjs = $applications->firstWhere('uuid', 'railpack-nestjs');
$angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static');
$eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static');
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
expect($nestjs)
->not->toBeNull()
->and($nestjs->base_directory)->toBe('/node/nestjs')
->and($nestjs->ports_exposes)->toBe('3000')
->and($nestjs->build_command)->toBe('npm run build')
->and($nestjs->start_command)->toBe('npm run start:prod')
->and($nestjs->settings->is_static)->toBeFalse();
expect($angularStatic)
->not->toBeNull()
->and($angularStatic->publish_directory)->toBe('/dist/static/browser')
->and($angularStatic->ports_exposes)->toBe('80')
->and($angularStatic->settings->is_static)->toBeTrue()
->and($angularStatic->settings->is_spa)->toBeTrue();
expect($eleventyStatic)
->not->toBeNull()
->and($eleventyStatic->publish_directory)->toBe('/_site')
->and($eleventyStatic->settings->is_static)->toBeTrue()
->and($eleventyStatic->settings->is_spa)->toBeFalse();
expect($pythonFlask)
->not->toBeNull()
->and($pythonFlask->ports_exposes)->toBe('5000')
->and($pythonFlask->start_command)->toBe('flask run --host=0.0.0.0 --port=5000');
expect($goGin)
->not->toBeNull()
->and($goGin->ports_exposes)->toBe('3000');
expect($rust)
->not->toBeNull()
->and($rust->ports_exposes)->toBe('8000');
});
it('skips the railpack examples outside development mode', function () {
config()->set('app.env', 'testing');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
});
it('is idempotent when run multiple times', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
expect($project)->not->toBeNull();
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
@@ -0,0 +1,39 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
use Livewire\Livewire;
it('rejects environment variable keys Docker cannot represent in the add form', function () {
Livewire::test(Add::class)
->set('key', 'BAD=KEY')
->set('value', 'value')
->call('submit')
->assertHasErrors(['key' => 'regex']);
});
it('allows Docker-compatible environment variable keys in the add form', function (string $key) {
Livewire::test(Add::class)
->set('key', $key)
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) use ($key) {
return data_get($data, 'key') === $key || data_get($data, '0.key') === $key;
});
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
]);
it('trims surrounding whitespace in environment variable keys in the add form', function () {
Livewire::test(Add::class)
->set('key', ' node.name ')
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) {
return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name';
});
});
@@ -130,6 +130,20 @@ describe('GetLogs Livewire action validation', function () {
});
});
describe('GetLogs stream polling', function () {
test('streaming logs polls when log panel is not collapsible', function () {
Livewire::test(GetLogs::class, [
'server' => $this->server,
'resource' => $this->application,
'container' => 'coolify-sentinel',
'collapsible' => false,
])
->assertDontSeeHtml('wire:poll.2000ms="getLogs(true)"')
->call('toggleStreamLogs')
->assertSeeHtml('wire:poll.2000ms="getLogs(true)"');
});
});
describe('GetLogs container name injection payloads are blocked by validation', function () {
test('newline injection payload is rejected', function () {
// The exact PoC payload from the advisory
@@ -5,8 +5,10 @@ use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -64,6 +66,21 @@ function fakeGithubHttp(array $repositories): void
]);
}
function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
{
$rsaKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($rsaKey, $pemKey);
return PrivateKey::create([
'name' => 'Test Key '.$team->id,
'private_key' => $pemKey,
'team_id' => $team->id,
]);
}
describe('GitHub Private Repository Component', function () {
test('loadRepositories fetches and displays repositories', function () {
$repos = [
@@ -81,6 +98,103 @@ describe('GitHub Private Repository Component', function () {
->assertSet('selected_repository_id', 1);
});
test('loadRepositories rejects a github app owned by another team', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$victimGithubApp = GithubApp::create([
'name' => 'Victim GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 54321,
'installation_id' => 98765,
'client_id' => 'victim-client-id',
'client_secret' => 'victim-client-secret',
'webhook_secret' => 'victim-webhook-secret',
'private_key_id' => $victimPrivateKey->id,
'team_id' => $victimTeam->id,
'is_public' => false,
'is_system_wide' => false,
]);
Http::fake();
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $victimGithubApp->id)
)->toThrow(ModelNotFoundException::class);
Http::assertNothingSent();
});
test('mount lists 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' => 98765,
'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,
]);
$component = 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)
->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 () {
expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse();
});
test('selected github app id cannot be tampered with from the client', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->set('selected_github_app_id', $this->githubApp->id);
})->throws(CannotUpdateLockedPropertyException::class);
test('loadRepositories can be called again to refresh the repository list', function () {
$initialRepos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
+104 -4
View File
@@ -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');
});
});
@@ -0,0 +1,157 @@
<?php
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function configurationCheckerApplication(Environment $environment, array $attributes = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => $environment->id,
'status' => 'running:healthy',
'build_command' => 'npm run build',
'fqdn' => 'https://example.com',
], $attributes));
}
function markConfigurationCheckerApplicationDeployed(Application $application): void
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => (string) $application->id,
'deployment_uuid' => (string) Str::uuid(),
'status' => 'finished',
'commit' => 'HEAD',
]);
$application->markDeploymentConfigurationApplied($deployment);
}
it('does not render the notification for preview deployment toggles', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$application->settings->update(['is_preview_deployments_enabled' => true]);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertDontSee('The latest deployment is not using the current configuration')
->assertSet('isConfigurationChanged', false);
});
it('renders the changed configuration labels', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('The latest configuration has not been applied')
->assertSee('Build command')
->assertSee('A rebuild is required.');
});
it('refreshes configuration changes when the event is received', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSet('isConfigurationChanged', false)
->assertDontSee('The latest configuration has not been applied');
$application->update(['build_command' => 'pnpm build']);
$component
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->assertSee('Build command');
});
it('refreshes stale modal configuration diff before opening changes', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('Build command')
->assertDontSee('Start command');
$application->update([
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$component
->call('refreshConfigurationChanges')
->assertSet('isConfigurationChanged', true)
->assertSee('Start command')
->assertDontSee('Build command');
});
it('does not render environment variable secret values', function () {
$application = configurationCheckerApplication($this->environment);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'old-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
markConfigurationCheckerApplicationDeployed($application->refresh());
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('old-secret')
->assertDontSee('new-secret');
});
it('renders added environment variables as set without exposing secret values', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'new-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('From')
->assertSee('-')
->assertSee('To')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('new-secret');
});
@@ -0,0 +1,108 @@
<?php
use App\Livewire\Project\Application\Advanced;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createApplicationForAdvancedStopGracePeriodTest(): Application
{
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::create([
'name' => 'stop-grace-period-test-app',
'git_repository' => 'https://github.com/coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
]);
}
beforeEach(function () {
$this->actingAs(User::factory()->create());
});
it('saves a valid stop grace period', function () {
$application = createApplicationForAdvancedStopGracePeriodTest();
Livewire::test(Advanced::class, ['application' => $application])
->set('stopGracePeriod', '300')
->call('saveStopGracePeriod')
->assertHasNoErrors()
->assertDispatched('success');
expect($application->settings()->first()->stop_grace_period)->toBe(300);
});
it('dispatches configuration changed when advanced settings are saved', function () {
$application = createApplicationForAdvancedStopGracePeriodTest();
Livewire::test(Advanced::class, ['application' => $application])
->set('includeSourceCommitInBuild', true)
->call('submit')
->assertHasNoErrors()
->assertDispatched('configurationChanged');
});
it('clears the stop grace period when submitted empty', function () {
$application = createApplicationForAdvancedStopGracePeriodTest();
$application->settings->update(['stop_grace_period' => 300]);
Livewire::test(Advanced::class, ['application' => $application->fresh()])
->set('stopGracePeriod', '')
->call('saveStopGracePeriod')
->assertHasNoErrors()
->assertDispatched('success');
expect($application->settings()->first()->stop_grace_period)->toBeNull();
});
it('rejects invalid stop grace periods', function (string $value, string $rule) {
$application = createApplicationForAdvancedStopGracePeriodTest();
Livewire::test(Advanced::class, ['application' => $application])
->set('stopGracePeriod', $value)
->call('saveStopGracePeriod')
->assertHasErrors(['stopGracePeriod' => [$rule]]);
expect($application->settings()->first()->stop_grace_period)->toBeNull();
})->with([
'below minimum' => ['0', 'min'],
'above maximum' => [(string) (MAX_STOP_GRACE_PERIOD_SECONDS + 1), 'max'],
'malformed integer' => ['10abc', 'integer'],
'decimal' => ['1.9', 'integer'],
]);
it('uses one second deployment timeout in local only when stop grace period is unset', function () {
config(['app.env' => 'local']);
$setting = new ApplicationSetting;
expect($setting->deploymentStopGracePeriodSeconds())->toBe(MIN_STOP_GRACE_PERIOD_SECONDS);
$setting->stop_grace_period = 10;
expect($setting->deploymentStopGracePeriodSeconds())->toBe(10);
});
it('uses default deployment timeout outside local when stop grace period is unset', function () {
config(['app.env' => 'production']);
$setting = new ApplicationSetting;
expect($setting->deploymentStopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
});
@@ -0,0 +1,121 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('PublicGitRepository port handling for railpack', function () {
test('switching to railpack resets port to 3000 when not static', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('build_pack', 'dockerfile')
->assertSet('port', 3000)
->set('build_pack', 'railpack')
->assertSet('port', 3000);
});
test('switching to railpack preserves port when isStatic is true', function () {
$component = Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('isStatic', true)
->call('instantSave');
// After instantSave with isStatic=true, port becomes 80
$component->assertSet('port', 80);
// Switching from nixpacks to railpack should NOT clobber port back to 3000
$component->set('build_pack', 'railpack')
->assertSet('port', 80);
});
test('switching to static sets port to 80 and disables show_is_static', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('build_pack', 'static')
->assertSet('port', 80)
->assertSet('isStatic', false)
->assertSet('show_is_static', false);
});
});
describe('General view railpack helper text', function () {
beforeEach(function () {
$this->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' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('railpack app shows railpack.json helper text and not nixpacks.toml', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'railpack',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('railpack.json')
->assertDontSee('nixpacks.toml');
});
test('nixpacks app shows nixpacks.toml helper text and not railpack.json', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('nixpacks.toml')
->assertDontSee('railpack.json');
});
});
+194
View File
@@ -0,0 +1,194 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->where('id', 0)->delete();
InstanceSettings::query()->delete();
$settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
});
function mcpPost(array $payload, ?string $token = null)
{
$headers = [
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
];
if ($token) {
$headers['Authorization'] = 'Bearer '.$token;
}
return test()->withHeaders($headers)->postJson('/mcp', $payload);
}
function mcpListTools(string $token)
{
return mcpPost([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/list',
'params' => (object) [],
], $token);
}
function mcpCallTool(string $token, string $name, array $arguments = [])
{
return mcpPost([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => $name,
'arguments' => (object) $arguments,
],
], $token);
}
function mcpToolJson($response): array
{
return json_decode($response->json('result.content.0.text'), true);
}
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
Once::flush();
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(404);
});
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);
});
test('MCP endpoint lists tools for an authenticated token', function () {
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpListTools($token);
$response->assertOk();
$toolNames = collect($response->json('result.tools'))->pluck('name')->all();
expect($toolNames)->toContain(
'get_infrastructure_overview',
'list_servers',
'get_server',
'list_projects',
'list_applications',
'get_application',
'list_databases',
'get_database',
'list_services',
'get_service',
);
expect($toolNames)->not->toContain('get_resource_status');
});
test('list_projects returns summary + pagination scoped to the token team', function () {
$project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
$otherTeam = Team::factory()->create();
Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'list_projects');
$response->assertOk();
$body = mcpToolJson($response);
expect($body)->toHaveKey('data');
expect($body)->toHaveKey('_pagination');
expect($body['_pagination']['total'])->toBe(1);
expect($body['_pagination']['per_page'])->toBe(50);
expect($body['_pagination'])->not->toHaveKey('next');
$uuids = collect($body['data'])->pluck('uuid')->all();
$names = collect($body['data'])->pluck('name')->all();
expect($uuids)->toContain($project->uuid);
expect($names)->not->toContain('Theirs');
expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']);
});
test('list_projects paginates with per_page cap at 100', function () {
for ($i = 0; $i < 3; $i++) {
Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]);
}
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]);
$body = mcpToolJson($response);
expect($body['_pagination']['total'])->toBe(3);
expect($body['_pagination']['total_pages'])->toBe(2);
expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]);
expect($body['data'])->toHaveCount(2);
// Verify max cap
$capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]);
$cappedBody = mcpToolJson($capped);
expect($cappedBody['_pagination']['per_page'])->toBe(100);
});
test('get_infrastructure_overview returns counts', function () {
Project::create(['name' => 'One', 'team_id' => $this->team->id]);
Project::create(['name' => 'Two', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'get_infrastructure_overview');
$response->assertOk();
$body = mcpToolJson($response);
expect($body)->toHaveKey('data');
expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
expect($body['data']['counts']['projects'])->toBe(2);
expect($body['data']['projects'])->toHaveCount(2);
expect($body['data']['projects'][0])->toHaveKey('counts');
});
test('get_server scrubs sensitive nested data and exposes connection_timeout', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
// creating hook auto-generates a sentinel_token; bump connection_timeout
// via saveQuietly to avoid triggering restartSentinel.
$server->settings->forceFill(['connection_timeout' => 42])->saveQuietly();
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]);
$response->assertOk();
$body = mcpToolJson($response);
$raw = json_encode($body);
expect($raw)->not->toContain('sentinel_token');
expect($raw)->not->toContain('"team_id"');
expect($raw)->not->toContain('"private_key_id"');
expect($body['data']['connection_timeout'])->toBe(42);
expect($body['data']['uuid'])->toBe($server->uuid);
});
test('tool calls fail when the token lacks the read ability', function () {
$token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
$response = mcpCallTool($token, 'list_projects');
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
});
+107
View File
@@ -0,0 +1,107 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->delete();
$settings = new InstanceSettings([
'is_mcp_server_enabled' => false,
'is_api_enabled' => true,
]);
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
});
function makeRootMcpToken(User $user): string
{
$token = $user->createToken('mcp-root', ['root']);
DB::table('personal_access_tokens')
->where('id', $token->accessToken->id)
->update(['team_id' => '0']);
return $token->plainTextToken;
}
function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string
{
$token = $user->createToken('mcp-write', $abilities);
DB::table('personal_access_tokens')
->where('id', $token->accessToken->id)
->update(['team_id' => (string) $team->id]);
return $token->plainTextToken;
}
test('POST /api/v1/mcp/enable enables MCP server with root token', function () {
$token = makeRootMcpToken($this->user);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertOk();
$response->assertJson(['message' => 'MCP server enabled.']);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
});
test('POST /api/v1/mcp/disable disables MCP server with root token', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
$token = makeRootMcpToken($this->user);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/disable');
$response->assertOk();
$response->assertJson(['message' => 'MCP server disabled.']);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
});
test('non-root token cannot enable MCP server', function () {
$token = makeNonRootMcpToken($this->user, $this->team);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertStatus(403);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
});
test('non-root token cannot disable MCP server', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
$token = makeNonRootMcpToken($this->user, $this->team);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/disable');
$response->assertStatus(403);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
});
test('unauthenticated request to /api/v1/mcp/enable returns 401', function () {
$response = test()->postJson('/api/v1/mcp/enable');
$response->assertStatus(401);
});
test('read-only token cannot toggle MCP server (lacks write ability)', function () {
$token = makeNonRootMcpToken($this->user, $this->team, ['read']);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertStatus(403);
});
@@ -299,6 +299,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
'inject_build_args_to_dockerfile' => true,
'include_source_commit_in_build' => true,
'docker_images_to_keep' => 5,
'stop_grace_period' => 300,
]);
expect($setting->exists)->toBeTrue();
@@ -309,6 +310,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
expect($setting->custom_internal_name)->toBe('my-custom-app');
expect($setting->is_spa)->toBeTrue();
expect($setting->docker_images_to_keep)->toBe(5);
expect($setting->stop_grace_period)->toBe(300);
});
it('creates ServerSetting with all fillable attributes', function () {
@@ -0,0 +1,49 @@
<?php
use App\Livewire\Project\New\GithubPrivateRepository;
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('new application buildpack defaults', function () {
test('github app repository flow defaults to nixpacks', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertSet('build_pack', 'nixpacks');
});
test('deploy key repository flow defaults to nixpacks', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->assertSet('build_pack', 'nixpacks');
});
test('public repository flow defaults to nixpacks and lists railpack second', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->assertSet('build_pack', 'nixpacks');
});
test('public repository flow keeps railpack available after branch lookup', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('branchFound', true)
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
});
test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->set('current_step', 'repository')
->assertSee('Railpack (Beta)');
});
});
+79
View File
@@ -0,0 +1,79 @@
<?php
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Socialite\Facades\Socialite;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create([
'id' => 0,
'is_registration_enabled' => false,
]);
OauthSetting::create([
'provider' => 'google',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
'tenant' => 'example.com',
]);
});
it('logs in an existing user when the oauth provider returns a mixed-case email', function () {
config()->set('app.maintenance.driver', 'file');
$user = User::factory()->create([
'email' => 'username@example.edu',
]);
$provider = \Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
'email' => 'UserName@example.edu',
'name' => 'Example User',
'id' => 'google-user-id',
]);
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
$response = $this->get(route('auth.callback', 'google'));
$response->assertRedirect('/');
$this->assertAuthenticatedAs($user);
expect(User::count())->toBe(1);
});
it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
config()->set('app.maintenance.driver', 'file');
InstanceSettings::firstOrCreate([
'id' => 0,
], [
'is_registration_enabled' => false,
])->update([
'is_registration_enabled' => true,
]);
$provider = \Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
'email' => $providerEmail,
'name' => 'Example User',
'id' => 'google-user-id',
]);
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
$response = $this->from('/login')->get(route('auth.callback', 'google'));
$response->assertRedirect('/login');
expect(User::count())->toBe(0);
})->with([
'null email' => [null],
'blank email' => [' '],
]);
+68
View File
@@ -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();
});
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Jobs\PullChangelog;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
/**
* Fake releases land in a month that no real release uses, so the generated
* changelog file never collides with committed changelogs.
*/
function fakeReleasesPayload(): array
{
return [
[
'tag_name' => 'v9.9.9',
'name' => 'Test Release',
'body' => 'Released notes here.',
'draft' => false,
'published_at' => '1999-01-15T00:00:00Z',
],
[
'tag_name' => 'v9.9.8-draft',
'name' => 'Draft Release',
'body' => 'Should be skipped.',
'draft' => true,
'published_at' => '1999-01-10T00:00:00Z',
],
];
}
afterEach(function () {
File::delete(base_path('changelogs/1999-01.json'));
});
test('releases_url config defaults to the GitHub raw source', function () {
expect(config('constants.coolify.releases_url'))
->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
});
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
Http::fake([
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
]);
(new PullChangelog)->handle();
Http::assertSent(fn ($request) => $request->url() === 'https://example.test/releases.json');
$path = base_path('changelogs/1999-01.json');
expect(File::exists($path))->toBeTrue();
$data = json_decode(File::get($path), true);
expect($data['entries'])->toHaveCount(1)
->and($data['entries'][0]['tag_name'])->toBe('v9.9.9');
});
test('PullChangelog skips draft releases', function () {
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
Http::fake([
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
]);
(new PullChangelog)->handle();
$data = json_decode(File::get(base_path('changelogs/1999-01.json')), true);
$tags = array_column($data['entries'], 'tag_name');
expect($tags)->not->toContain('v9.9.8-draft');
});
@@ -16,10 +16,29 @@ beforeEach(function () {
Cache::flush();
});
it('dispatches storage check when disk percentage changes', function () {
it('dispatches storage check when disk percentage changes above threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Default notification threshold is 80%.
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $data);
$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 usage is below threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// 45% is well below the default 80% notification threshold — nothing to do.
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
@@ -28,8 +47,39 @@ it('dispatches storage check when disk percentage changes', function () {
$job = new PushServerUpdateJob($server, $data);
$job->handle();
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 === 45;
return $job->server->id === $server->id && $job->percentage === 85;
});
});
@@ -37,12 +87,12 @@ it('does not dispatch storage check when disk percentage is unchanged', function
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Simulate a previous push that cached the percentage
Cache::put('storage-check:'.$server->id, 45, 600);
// Simulate a previous push that cached the percentage (above threshold).
Cache::put('storage-check:'.$server->id, 85, 600);
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $data);
@@ -55,19 +105,19 @@ it('dispatches storage check when disk percentage changes from cached value', fu
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
// Simulate a previous push that cached 45%
Cache::put('storage-check:'.$server->id, 45, 600);
// Simulate a previous push that cached 85% (above threshold).
Cache::put('storage-check:'.$server->id, 85, 600);
$data = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 50],
'filesystem_usage_root' => ['used_percentage' => 90],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 50;
return $job->server->id === $server->id && $job->percentage === 90;
});
});
@@ -140,6 +190,36 @@ it('dispatches ConnectProxyToNetworksJob again after cache expires', function ()
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
});
it('respects the configured proxy connect interval', function () {
// Interval 0 → the connect-proxy gate key expires immediately, so every
// push re-dispatches without a manual Cache::forget. Proves the TTL is
// driven by config('constants.proxy.connect_networks_interval_seconds').
config(['constants.proxy.connect_networks_interval_seconds' => 0]);
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$data = [
'containers' => [
[
'name' => 'coolify-proxy',
'state' => 'running',
'health_status' => 'healthy',
'labels' => ['coolify.managed' => true],
],
],
'filesystem_usage_root' => ['used_percentage' => 10],
];
(new PushServerUpdateJob($server, $data))->handle();
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
Queue::fake();
(new PushServerUpdateJob($server, $data))->handle();
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
});
it('uses default queue for PushServerUpdateJob', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
@@ -0,0 +1,70 @@
<?php
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);
beforeEach(function () {
$this->teamA = Team::factory()->create();
$this->teamB = Team::factory()->create();
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first();
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]);
});
test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () {
$database = StandalonePostgresql::create([
'name' => 'pg-team-a',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->envA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id);
expect($found)->not->toBeNull();
expect($found->uuid)->toBe($database->uuid);
expect($found)->toBeInstanceOf(StandalonePostgresql::class);
});
test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () {
$database = StandalonePostgresql::create([
'name' => 'pg-team-a',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->envA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id);
expect($found)->toBeNull();
});
test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () {
$found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id);
expect($found)->toBeNull();
});
test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () {
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
$count = $modelClass::query()->whereUuid('non-existent-uuid')->count();
expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query");
}
});
@@ -0,0 +1,107 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake([ApplicationDeploymentJob::class]);
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'test-network-'.fake()->unique()->word(),
]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function makeApplication(int $environmentId, int $destinationId, ?string $gitCommitSha): Application
{
$attributes = [
'environment_id' => $environmentId,
'destination_id' => $destinationId,
'destination_type' => StandaloneDocker::class,
];
if ($gitCommitSha !== null) {
$attributes['git_commit_sha'] = $gitCommitSha;
}
return Application::factory()->create($attributes);
}
describe('queue_application_deployment commit resolution', function () {
test('uses application git_commit_sha when commit parameter omitted', function () {
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
$result = queue_application_deployment(
application: $application,
deployment_uuid: 'test-deploy-uuid-1',
);
expect($result['status'])->toBe('queued');
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-1')->first();
expect($deployment)->not->toBeNull();
expect($deployment->commit)->toBe($pinnedSha);
});
test('falls back to HEAD when both commit parameter and git_commit_sha are unset', function () {
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
$result = queue_application_deployment(
application: $application,
deployment_uuid: 'test-deploy-uuid-2',
);
expect($result['status'])->toBe('queued');
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-2')->first();
expect($deployment->commit)->toBe('HEAD');
});
test('explicit commit parameter overrides application git_commit_sha', function () {
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
$webhookSha = '111222333444555666777888999000aaabbbccc1';
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
$result = queue_application_deployment(
application: $application,
deployment_uuid: 'test-deploy-uuid-3',
commit: $webhookSha,
);
expect($result['status'])->toBe('queued');
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-3')->first();
expect($deployment->commit)->toBe($webhookSha);
});
test('treats empty string commit parameter as unset and uses git_commit_sha', function () {
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
$result = queue_application_deployment(
application: $application,
deployment_uuid: 'test-deploy-uuid-4',
commit: '',
);
expect($result['status'])->toBe('queued');
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-4')->first();
expect($deployment->commit)->toBe($pinnedSha);
});
});
+74
View File
@@ -0,0 +1,74 @@
<?php
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Service\StartService;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\ScheduledJobManager;
use App\Models\ScheduledDatabaseBackup;
describe('deployment_queue helper', function () {
test('uses the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect(deployment_queue())->toBe('high');
});
test('uses the deployments queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect(deployment_queue())->toBe('deployments');
});
});
describe('crons_queue helper', function () {
test('uses the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect(crons_queue())->toBe('high');
});
test('uses the crons queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect(crons_queue())->toBe('crons');
});
});
describe('start action job routing', function () {
test('routes to the deployments queue on cloud', function (string $actionClass) {
config(['constants.coolify.self_hosted' => false]);
expect($actionClass::makeJob()->queue)->toBe('deployments');
})->with([
StartDatabase::class,
StartDatabaseProxy::class,
StartService::class,
]);
test('routes to the high queue on self-hosted', function (string $actionClass) {
config(['constants.coolify.self_hosted' => true]);
expect($actionClass::makeJob()->queue)->toBe('high');
})->with([
StartDatabase::class,
StartDatabaseProxy::class,
StartService::class,
]);
});
describe('scheduled job routing', function () {
test('scheduled jobs use the crons queue on cloud', function () {
config(['constants.coolify.self_hosted' => false]);
expect((new ScheduledJobManager)->queue)->toBe('crons');
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('crons');
});
test('scheduled jobs use the high queue on self-hosted', function () {
config(['constants.coolify.self_hosted' => true]);
expect((new ScheduledJobManager)->queue)->toBe('high');
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('high');
});
});
@@ -32,3 +32,75 @@ it('keeps realtime terminal server logging restricted to development environment
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});
it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain('ws.isAlive = true;')
->toContain("ws.on('pong'")
->toContain('ws.ping();')
->toContain('ws.terminate();')
->toContain('HEARTBEAT_INTERVAL_MS');
});
it('removes the keepalive short-circuit that fired when the tab was hidden', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects');
});
it('uses a fast probe timeout when the tab regains visibility', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain("'Visibility-resume timeout'");
});
it('closes idle 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');");
});
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', 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.');
});
it('replays the last command on reconnect so the PTY respawns automatically', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('lastSentCommand')
->toContain('Replaying last command after reconnect.')
->toContain('this.lastSentCommand = null;');
});
it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain('authReady: false')
->toContain('pendingMessages: []')
->toContain('userSession.pendingMessages.push(message)')
->toContain('userSession.authReady = true');
});
it('preserves terminal scrollback across transient reconnects', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('── Connection lost at')
->toContain('── Reconnected at')
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
->not->toContain("this.term.reset();\n this.term.clear();");
});
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]));
});
it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server double dispatch', function () {
$schedule = app(Schedule::class);
$event = collect($schedule->events())->first(
fn ($e) => str_contains((string) $e->description, 'RegenerateSslCertJob')
);
expect($event)->not->toBeNull();
expect($event->onOneServer)->toBeTrue();
});
it('schedules every production job with onOneServer', function () {
$schedule = app(Schedule::class);
$jobEvents = collect($schedule->events())->filter(
fn ($e) => str_contains((string) $e->description, 'App\\Jobs\\')
);
expect($jobEvents)->not->toBeEmpty();
$jobEvents->each(function ($event) {
expect($event->onOneServer)->toBeTrue(
"Scheduled job [{$event->description}] is missing ->onOneServer()"
);
});
});
+66
View File
@@ -0,0 +1,66 @@
<?php
use App\Models\Application;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first();
});
it('returns null when neither application nor service is set', function () {
$task = ScheduledTask::factory()->create([
'team_id' => $this->team->id,
]);
expect($task->server())->toBeNull();
});
it('does not throw when accessing dynamic properties on a parentless task', function () {
$task = ScheduledTask::factory()->create([
'team_id' => $this->team->id,
]);
expect(fn () => $task->server())->not->toThrow(Exception::class);
});
it('resolves server via application destination', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'application_id' => $application->id,
'team_id' => $this->team->id,
]);
expect($task->server()?->id)->toBe($this->server->id);
});
it('resolves server via service destination', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$task = ScheduledTask::factory()->create([
'service_id' => $service->id,
'team_id' => $this->team->id,
]);
expect($task->server()?->id)->toBe($this->server->id);
});
+445
View File
@@ -0,0 +1,445 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
function makeAuditTeamUser(): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
test()->actingAs($user);
return [$team, $user];
}
function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string
{
$token = $user->createToken('audit-test', $abilities);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
'team_id' => $team->id,
]);
return $token->plainTextToken;
}
function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
return Application::create([
'name' => 'audit-test-app',
'git_repository' => "https://github.com/{$repo}",
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
describe('audit channel helper', function () {
test('auditLog writes structured payload to audit channel', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('test.event', Mockery::on(function ($context) {
return $context['event'] === 'test.event'
&& $context['custom_field'] === 'value'
&& array_key_exists('ip', $context)
&& array_key_exists('user_id', $context);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLog('test.event', ['custom_field' => 'value']);
});
test('auditLog warning level routes correctly', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLog('test.failed', [], 'warning');
});
test('auditLogWebhookFailure logs warning with provider tag', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->once()
->with('webhook.github.signature_failed', Mockery::on(function ($context) {
return $context['reason'] === 'invalid_signature'
&& $context['event'] === 'webhook.github.signature_failed'
&& array_key_exists('ip', $context);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']);
});
test('auditLog never includes raw secret keys in context', function () {
$captured = null;
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) {
$captured = $context;
return true;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
auditLog('test.private_key.created', [
'team_id' => '1',
'private_key_uuid' => 'abc',
'fingerprint' => 'SHA256:xyz',
]);
expect($captured)->toBeArray();
// Helper itself never injects secret-bearing keys.
$disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret'];
foreach (array_keys($captured) as $key) {
expect(in_array(strtolower($key), $disallowed, true))->toBeFalse();
}
});
});
describe('webhook signature failure logging', function () {
test('GitHub manual webhook with bad signature logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.github.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('GitLab manual webhook with bad token logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.gitlab.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
], [
'X-Gitlab-Token' => 'wrong-token',
]);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('Bitbucket manual webhook with malformed signature logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.bitbucket.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => 'test-org/test-repo'],
]);
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha1=anyvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('Gitea manual webhook with bad signature logs to audit channel', function () {
$app = makeAuditApplication();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.gitea.signature_failed', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
'HTTP_X-Gitea-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
});
describe('API mutation audit logging', function () {
test('private key creation emits api.private_key.created audit event', function () {
[$team, $user] = makeAuditTeamUser();
$token = makeAuditApiToken($user, $team);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->atLeast()
->once()
->with('api.private_key.created', Mockery::on(function ($context) {
return $context['event'] === 'api.private_key.created'
&& ! array_key_exists('private_key', $context);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
// Generate a valid OpenSSH-format private key for the test.
$opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
base64_encode(str_repeat('a', 256)).
"\n-----END OPENSSH PRIVATE KEY-----";
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/security/keys', [
'name' => 'test-key',
'description' => 'audit test',
'private_key' => $opensshKey,
]);
// Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201.
expect($response->status())->toBeIn([201, 422]);
});
test('enable_api denial for non-root team emits warning audit event', function () {
[$team, $user] = makeAuditTeamUser();
$token = makeAuditApiToken($user, $team);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.instance.enable_denied', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/enable');
$response->assertStatus(403);
});
test('project creation emits api.project.created audit event', function () {
[$team, $user] = makeAuditTeamUser();
$token = makeAuditApiToken($user, $team);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->atLeast()
->once()
->with('api.project.created', Mockery::on(function ($context) {
return $context['event'] === 'api.project.created'
&& ! empty($context['project_uuid'])
&& $context['project_name'] === 'audit-project';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'audit-project',
'description' => 'audit',
]);
$response->assertStatus(201);
});
});
describe('threat-detection audit logging (Phase 2)', function () {
test('missing bearer token logs api.auth.unauthenticated', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.auth.unauthenticated', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->getJson('/api/v1/projects');
$response->assertStatus(401);
});
test('expired bearer token logs api.auth.unauthenticated', function () {
[$team, $user] = makeAuditTeamUser();
$token = $user->createToken('expired-audit', ['read'], now()->subDay());
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
'team_id' => $team->id,
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.auth.unauthenticated', Mockery::any());
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/projects');
$response->assertStatus(401);
});
test('read-only token hitting write endpoint logs api.auth.ability_denied', function () {
[$team, $user] = makeAuditTeamUser();
$readToken = makeAuditApiToken($user, $team, ['read']);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('api.auth.ability_denied', Mockery::on(function ($ctx) {
return in_array('write', $ctx['required_abilities'] ?? [], true);
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'should-fail',
]);
$response->assertStatus(403);
});
test('sentinel push without Authorization logs token_missing', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
return $ctx['reason'] === 'token_missing';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->postJson('/api/v1/sentinel/push', []);
$response->assertStatus(401);
});
test('sentinel push with un-decryptable bearer logs decrypt_failed', function () {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('warning')
->atLeast()
->once()
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
return $ctx['reason'] === 'decrypt_failed';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
Log::shouldReceive('info')->andReturnNull();
Log::shouldReceive('error')->andReturnNull();
$response = $this->withHeaders([
'Authorization' => 'Bearer not-a-valid-encrypted-payload',
])->postJson('/api/v1/sentinel/push', []);
$response->assertStatus(401);
});
});
@@ -0,0 +1,257 @@
<?php
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->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,
]);
});
function cacheGithubAppSetupState(string $state, string $action, GithubApp $githubApp): void
{
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
'action' => $action,
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
], now()->addMinutes(15));
}
function authenticateGithubSetupCallbackTest(object $test): void
{
$test->actingAs($test->user);
session(['currentTeam' => $test->team]);
}
function fakeGithubManifestConversion(): void
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/app-manifests/*/conversions' => Http::response([
'id' => 987654,
'slug' => 'attacker-controlled-app',
'client_id' => 'new-client-id',
'client_secret' => 'new-client-secret',
'pem' => $privateKey,
'webhook_secret' => 'new-webhook-secret',
]),
]);
}
function configureGithubAppCredentials(GithubApp $githubApp): void
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
$privateKeyModel = PrivateKey::create([
'name' => 'github-app-test-key',
'private_key' => $privateKey,
'team_id' => $githubApp->team_id,
'is_git_related' => true,
]);
$githubApp->forceFill([
'app_id' => 123456,
'private_key_id' => $privateKeyModel->id,
])->save();
}
function fakeGithubInstallationVerification(int $appId): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/*' => Http::response([
'id' => 555,
'app_id' => $appId,
], 200),
]);
}
function fakeGithubInstallationVerificationFailure(): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/*' => Http::response(['message' => 'Not Found'], 404),
]);
}
it('requires authentication before processing github app manifest callbacks', function () {
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
->assertRedirect();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBeNull()
->and($this->githubApp->client_id)->toBeNull()
->and($this->githubApp->webhook_secret)->toBeNull();
});
it('rejects github app manifest callbacks with invalid state without calling github', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state='.$this->githubApp->uuid.'&code=attacker-code')
->assertNotFound();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBeNull()
->and($this->githubApp->client_id)->toBeNull()
->and($this->githubApp->webhook_secret)->toBeNull();
});
it('blocks rebinding an already configured github app through manifest callback', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
$this->githubApp->forceFill([
'app_id' => 123456,
'client_id' => 'existing-client-id',
'client_secret' => 'existing-client-secret',
'webhook_secret' => 'existing-webhook-secret',
])->save();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
->assertForbidden();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->app_id)->toBe(123456)
->and($this->githubApp->client_id)->toBe('existing-client-id')
->and($this->githubApp->webhook_secret)->toBe('existing-webhook-secret');
});
it('configures an unbound github app with a valid one-time manifest state', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertSentCount(1);
$this->githubApp->refresh();
expect($this->githubApp->name)->toBe('attacker-controlled-app')
->and($this->githubApp->app_id)->toBe(987654)
->and($this->githubApp->client_id)->toBe('new-client-id')
->and($this->githubApp->webhook_secret)->toBe('new-webhook-secret')
->and($this->githubApp->private_key_id)->not->toBeNull();
});
it('rejects replayed github app manifest states', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertRedirect();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertNotFound();
Http::assertSentCount(1);
});
it('requires authentication before processing github app install callbacks', function () {
Http::preventStrayRequests();
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertRedirect();
Http::assertNothingSent();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBeNull();
});
it('rejects github app install callbacks for an unknown github app', function () {
authenticateGithubSetupCallbackTest($this);
Http::preventStrayRequests();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
->assertNotFound();
Http::assertNothingSent();
});
it('rejects an installation id that github does not confirm belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerificationFailure();
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
->assertForbidden();
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBeNull();
});
it('sets installation id when github confirms it belongs to the app', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(123456);
});
it('allows reinstalling an already configured github app installation id', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
$this->githubApp->forceFill(['installation_id' => 111111])->save();
fakeGithubInstallationVerification($this->githubApp->app_id);
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
$this->githubApp->refresh();
expect($this->githubApp->installation_id)->toBe(222222);
});
@@ -0,0 +1,179 @@
<?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;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array']);
Queue::fake();
Cache::flush();
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
$this->token = $this->server->settings->sentinel_token;
});
function pushSentinel(string $token, array $payload)
{
return test()->postJson('/api/v1/sentinel/push', $payload, [
'Authorization' => 'Bearer '.$token,
]);
}
function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): array
{
return [
'containers' => $containers,
'filesystem_usage_root' => ['used_percentage' => $diskPercentage],
];
}
$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();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('skips the job when the second push is identical', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('updates the heartbeat even when the job is skipped', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
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();
$exited = [['name' => 'app-1', 'state' => 'exited', 'health_status' => 'unhealthy']];
pushSentinel($this->token, sentinelPayload($exited))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
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();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('ignores container reordering (hash is sorted by name)', function () {
$order1 = [
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
];
$order2 = [
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
];
pushSentinel($this->token, sentinelPayload($order1))->assertOk();
pushSentinel($this->token, sentinelPayload($order2))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('force-dispatches an identical push after the force window expires', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
// Simulate the force key TTL elapsing.
Cache::forget('sentinel:push-force:'.$this->server->id);
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
it('rejects an invalid token without dispatching', function () use ($running) {
pushSentinel('not-a-real-token', sentinelPayload($running()))->assertUnauthorized();
Queue::assertNotPushed(PushServerUpdateJob::class);
});
@@ -4,6 +4,7 @@ use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
@@ -78,11 +79,73 @@ describe('ServerSetting::isValidSentinelToken', function () {
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
});
it('returns false for null sentinel token', function () {
expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
});
it('rejects the reported PoC payload', function () {
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
});
});
describe('ServerSetting::ensureValidSentinelToken', function () {
it('regenerates empty sentinel token via ensureValidSentinelToken', function () {
$settings = $this->server->settings;
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
$settings->refresh();
$token = $settings->ensureValidSentinelToken();
expect($token)->not->toBeEmpty();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
expect($settings->fresh()->sentinel_token)->toBe($token);
});
it('regenerates token when stored value cannot be decrypted', function () {
$settings = $this->server->settings;
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
$settings->refresh();
$token = $settings->ensureValidSentinelToken();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
expect($settings->fresh()->sentinel_token)->toBe($token);
});
it('returns existing valid token without regenerating', function () {
$settings = $this->server->settings;
$original = $settings->sentinel_token;
$token = $settings->ensureValidSentinelToken();
expect($token)->toBe($original);
});
it('throws RuntimeException only when regeneration also fails', function () {
$settings = $this->server->settings;
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
$stub = new class extends ServerSetting
{
protected $table = 'server_settings';
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
DB::table('server_settings')->where('id', $this->id)->update([
'sentinel_token' => encrypt('invalid token with spaces!'),
]);
return '';
}
};
$stub->setRawAttributes($settings->fresh()->getAttributes(), true);
$stub->exists = true;
expect(fn () => $stub->ensureValidSentinelToken())
->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration');
});
});
describe('generated sentinel tokens are valid', function () {
it('generates tokens that pass format validation', function () {
$settings = $this->server->settings;
@@ -92,4 +155,11 @@ describe('generated sentinel tokens are valid', function () {
expect($token)->not->toBeEmpty();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
});
it('returns the same value the cast reads back', function () {
$settings = $this->server->settings;
$returned = $settings->generateSentinelToken(save: true, ignoreEvent: true);
expect($settings->fresh()->sentinel_token)->toBe($returned);
});
});
@@ -0,0 +1,74 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
$newToken = $this->user->createToken('write-token', ['write']);
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->token = $newToken->plainTextToken;
});
it('PATCH updates connection_timeout via API', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 45,
]);
$response->assertStatus(201);
expect($this->server->settings->fresh()->connection_timeout)->toBe(45);
});
it('PATCH rejects connection_timeout out of range', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 0,
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});
it('PATCH rejects connection_timeout above max', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 999,
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});
it('PATCH rejects non-integer connection_timeout', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
'connection_timeout' => 'fast',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
});
@@ -0,0 +1,43 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
it('defaults connection_timeout to 10 seconds for new servers', function () {
expect($this->server->settings->connection_timeout)->toBe(10);
});
it('persists a custom connection_timeout value', function () {
$this->server->settings->connection_timeout = 30;
$this->server->settings->save();
expect($this->server->settings->fresh()->connection_timeout)->toBe(30);
});
it('returns the per-server connection_timeout from getConnectionTimeout', function () {
$this->server->settings->connection_timeout = 45;
$this->server->settings->save();
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45);
});
it('falls back to config default when connection_timeout is invalid', function () {
$this->server->settings->connection_timeout = 0;
$this->server->settings->saveQuietly();
$expected = (int) config('constants.ssh.connection_timeout');
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected);
});
@@ -0,0 +1,107 @@
<?php
use App\Livewire\Destination\New\Docker;
use App\Livewire\Server\Destinations;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('destination creation modal can mount with selected team server even when global usable server list excludes it', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
Livewire::test(Docker::class, ['server_id' => (string) $server->id])
->assertSet('selectedServer.id', $server->id)
->assertSet('serverId', (string) $server->id);
});
test('server destinations page renders when selected server has no destinations', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
$this->get(route('server.destinations', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertSee('Destinations')
->assertSee('No destinations configured for this server yet.')
->assertDontSee('Server not found.');
});
test('global destinations page does not render per-server empty states beside existing destinations', function () {
$serverWithDestination = Server::factory()->create(['team_id' => $this->team->id]);
$serverWithDestination->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
$serverWithoutDestination = Server::factory()->create(['team_id' => $this->team->id]);
$serverWithoutDestination->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(fn () => $serverWithoutDestination->standaloneDockers()->delete());
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee($serverWithDestination->standaloneDockers()->first()->name)
->assertDontSee('No destinations found.');
});
test('global destinations page renders a single empty state when no usable servers have destinations', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee('No destinations found.');
});
test('adding a discovered swarm destination stores the selected network name', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_swarm_manager' => true,
]);
Livewire::test(Destinations::class, ['server_uuid' => $server->uuid])
->call('add', 'customer-network');
expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue();
});
@@ -0,0 +1,105 @@
<?php
use App\Events\ServerReachabilityChanged;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Channels\EmailChannel;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->team->emailNotificationSettings()->update([
'use_instance_email_settings' => true,
'server_unreachable_email_notifications' => true,
'server_reachable_email_notifications' => true,
]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
Notification::fake();
});
it('sends Unreachable notification when threshold reached and not yet notified', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 2,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertSentTo($this->team, Unreachable::class);
expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue();
});
it('does not send Unreachable on first transient failure (count=1)', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 1,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertNothingSent();
});
it('does not send Unreachable when already notified', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 5,
'unreachable_notification_sent' => true,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertNothingSent();
});
it('sends Reachable notification on recovery when previously notified', function () {
$this->server->settings()->update(['is_reachable' => true]);
$this->server->forceFill([
'unreachable_count' => 0,
'unreachable_notification_sent' => true,
])->save();
$fresh = $this->server->fresh();
expect($fresh->unreachable_notification_sent)->toBeTrue();
expect((bool) $fresh->settings->is_reachable)->toBeTrue();
ServerReachabilityChanged::dispatch($fresh);
Notification::assertSentTo($this->team, Reachable::class);
expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse();
});
it('does not send Reachable when never notified', function () {
$this->server->settings()->update(['is_reachable' => true]);
$this->server->forceFill([
'unreachable_count' => 0,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertNothingSent();
});
it('routes Unreachable notification through EmailChannel when email toggle is on', function () {
$this->server->settings()->update(['is_reachable' => false]);
$this->server->forceFill([
'unreachable_count' => 2,
'unreachable_notification_sent' => false,
])->save();
ServerReachabilityChanged::dispatch($this->server->fresh());
Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) {
return in_array(EmailChannel::class, $channels);
});
});
@@ -0,0 +1,141 @@
<?php
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\Heading;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('cache.default', 'array');
Config::set('app.maintenance.store', 'array');
Config::set('queue.default', 'sync');
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
Once::flush();
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'team-a-network',
]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'network' => 'team-b-network',
]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->otherService = Service::factory()->create([
'server_id' => $this->serverB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => $this->destinationB->getMorphClass(),
'environment_id' => $this->environmentB->id,
]);
$this->otherServiceApplication = ServiceApplication::create([
'service_id' => $this->otherService->id,
'name' => 'other-app',
'image' => 'nginx:alpine',
]);
$this->otherServiceDatabase = ServiceDatabase::create([
'service_id' => $this->otherService->id,
'name' => 'other-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->ownService = Service::factory()->create([
'server_id' => $this->serverA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
'environment_id' => $this->environmentA->id,
]);
$this->ownServiceDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'own-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('does not open service application detail route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceApplication->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not open service database backups route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not resolve service database import component from another team', function () {
$component = app(DatabaseImport::class);
$component->parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
];
$component->getContainers();
})->throws(ModelNotFoundException::class);
test('service heading does not hydrate with another team service', function () {
Livewire::test(Heading::class, ['service' => $this->otherService]);
})->throws(ModelNotFoundException::class);
test('owner can still hydrate service heading with own service', function () {
Livewire::test(Heading::class, [
'service' => $this->ownService,
'parameters' => [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
],
])
->assertOk();
});
+116
View File
@@ -0,0 +1,116 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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.
*/
uses(RefreshDatabase::class);
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".
'-----END OPENSSH PRIVATE KEY-----';
$privateKey = PrivateKey::create([
'name' => 'mux-test-key-'.uniqid(),
'private_key' => $privateKeyContent,
'team_id' => $team->id,
]);
Storage::fake('ssh-keys');
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
return Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
}
it('does not prewarm a background ssh master', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Process::fake();
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue();
Process::assertNothingRan();
});
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]);
$server = makeMuxServer();
Process::fake();
$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')
->not->toContain('-O check')
->not->toContain('ssh -fN');
Process::assertNothingRan();
});
it('returns false and runs no process when multiplexing is globally disabled', function () {
config(['constants.ssh.mux_enabled' => false]);
$server = makeMuxServer();
Process::fake();
expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse();
Process::assertNothingRan();
});
@@ -0,0 +1,71 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function attachDb(string $modelClass, array $extra, $destination, $environment)
{
return $modelClass::create(array_merge([
'name' => 'test-'.strtolower(class_basename($modelClass)),
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
], $extra));
}
test('StandaloneDocker::databases() includes attached keydb', function () {
attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(1);
expect($this->destination->attachedTo())->toBeTrue();
});
test('StandaloneDocker::databases() includes attached dragonfly', function () {
attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(1);
expect($this->destination->attachedTo())->toBeTrue();
});
test('StandaloneDocker::databases() includes attached clickhouse', function () {
attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(1);
expect($this->destination->attachedTo())->toBeTrue();
});
test('StandaloneDocker::databases() includes all 8 standalone database types', function () {
attachDb(StandalonePostgresql::class, ['postgres_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneRedis::class, ['redis_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneMongodb::class, ['mongo_initdb_root_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneMysql::class, ['mysql_root_password' => 'pw', 'mysql_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneMariadb::class, ['mariadb_root_password' => 'pw', 'mariadb_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(8);
expect($this->destination->attachedTo())->toBeTrue();
});
@@ -2,10 +2,14 @@
use App\Jobs\ServerLimitCheckJob;
use App\Jobs\StripeProcessJob;
use App\Jobs\SubscriptionInvoiceFailedJob;
use App\Jobs\VerifyStripeSubscriptionStatusJob;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Internal\GeneralNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -228,3 +232,65 @@ describe('ServerLimitCheckJob dispatch is guarded by team check', function () {
Queue::assertNotPushed(ServerLimitCheckJob::class);
});
});
describe('missing subscription Stripe webhooks are ignored', function () {
test('does not send internal notifications or queue follow-up jobs', function (array $event) {
Queue::fake();
$rootTeam = Team::factory()->create(['id' => 0]);
$rootTeam->discordNotificationSettings()->update(['discord_enabled' => true]);
Notification::fake();
$job = new StripeProcessJob($event);
$job->handle();
Notification::assertNothingSent();
Notification::assertNotSentTo($rootTeam, GeneralNotification::class);
Queue::assertNotPushed(SubscriptionInvoiceFailedJob::class);
Queue::assertNotPushed(VerifyStripeSubscriptionStatusJob::class);
})->with([
'invoice paid' => [[
'type' => 'invoice.paid',
'data' => [
'object' => [
'customer' => 'cus_missing_invoice_paid',
'amount_paid' => 1000,
'subscription' => 'sub_missing_invoice_paid',
'lines' => [
'data' => [[
'plan' => ['id' => 'price_dynamic_monthly'],
]],
],
],
],
]],
'invoice payment failed' => [[
'type' => 'invoice.payment_failed',
'data' => [
'object' => [
'customer' => 'cus_missing_invoice_payment_failed',
'id' => 'in_missing_invoice_payment_failed',
'payment_intent' => null,
],
],
]],
'payment intent payment failed' => [[
'type' => 'payment_intent.payment_failed',
'data' => [
'object' => [
'customer' => 'cus_missing_payment_intent_failed',
],
],
]],
'customer subscription deleted' => [[
'type' => 'customer.subscription.deleted',
'data' => [
'object' => [
'customer' => 'cus_missing_subscription_deleted',
'id' => 'sub_missing_subscription_deleted',
],
],
]],
]);
});
@@ -0,0 +1,65 @@
<?php
use App\Exceptions\DeploymentException;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\TimeoutExceededException;
use Laravel\Horizon\Contracts\JobRepository;
use Mockery\MockInterface;
function fakeJob(string $uuid): Job
{
$job = Mockery::mock(Job::class)->shouldIgnoreMissing();
$job->shouldReceive('uuid')->andReturn($uuid);
$job->shouldReceive('getJobId')->andReturn($uuid);
return $job;
}
function fireJobFailed(Job $job, Throwable $exception): void
{
event(new JobFailed('redis', $job, $exception));
}
beforeEach(function () {
config(['constants.coolify.self_hosted' => false]);
});
test('scrubs Horizon failed entry for DeploymentException on cloud', function () {
$uuid = 'uuid-deployment-1';
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
});
fireJobFailed(fakeJob($uuid), new DeploymentException('build failed'));
});
test('scrubs Horizon failed entry for TimeoutExceededException on cloud', function () {
$uuid = 'uuid-timeout-1';
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
});
fireJobFailed(fakeJob($uuid), new TimeoutExceededException('worker timeout'));
});
test('does not scrub generic exceptions on cloud', function () {
$this->mock(JobRepository::class, function (MockInterface $mock) {
$mock->shouldNotReceive('deleteFailed');
});
fireJobFailed(fakeJob('uuid-generic-1'), new RuntimeException('boom'));
});
test('does not scrub when self-hosted even for filtered exceptions', function () {
config(['constants.coolify.self_hosted' => true]);
$this->mock(JobRepository::class, function (MockInterface $mock) {
$mock->shouldNotReceive('deleteFailed');
});
fireJobFailed(fakeJob('uuid-deployment-2'), new DeploymentException('build failed'));
fireJobFailed(fakeJob('uuid-timeout-2'), new TimeoutExceededException('worker timeout'));
});
+48
View File
@@ -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');
});
+32
View File
@@ -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');
});
+225 -4
View File
@@ -51,7 +51,7 @@ describe('GitHub Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
@@ -118,7 +118,7 @@ describe('GitLab Manual Webhook HMAC', function () {
]);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with wrong token', function () {
@@ -178,7 +178,7 @@ describe('Bitbucket Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with non-sha256 algorithm', function () {
@@ -263,7 +263,7 @@ describe('Gitea Manual Webhook HMAC', function () {
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Webhook secret not configured');
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
@@ -312,6 +312,227 @@ describe('Gitea Manual Webhook HMAC', function () {
});
});
describe('Manual Webhook Repository Matching', function () {
test('github rejects empty repository without leaking applications', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github does not match repository substrings', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('No applications found')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github invalid signature does not leak matched application identifiers', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid signature')
->not->toContain('secret-github-app')
->not->toContain($app->uuid)
->not->toContain('application_uuid')
->not->toContain('application_name');
});
test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => ''],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => ''],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('No applications found')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => 'test-org/test'],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc123',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('github matches ssh git repository URL exactly', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@github.com:test-org/test-repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$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',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
});
describe('Webhook Secret Auto-Generation', function () {
test('auto-generates webhook secrets on application creation', function () {
$app = createApplicationWithWebhook();
@@ -0,0 +1,249 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use Illuminate\Support\Collection;
use Tests\TestCase;
uses(TestCase::class);
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public function __construct() {}
public function execute_remote_command(...$commands)
{
$this->recordedCommands[] = $commands;
}
}
function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array
{
$job = new TestableRailpackDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$application = new Application($applicationAttributes);
foreach ([
'application' => $application,
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'saved_outputs' => new Collection($savedOutputs),
'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'",
'force_rebuild' => false,
'addHosts' => '',
'secrets_hash_key' => 'testing-app-key',
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection];
}
function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invokeArgs($job, $arguments);
}
it('deep merges repository railpack config with coolify overrides', function () {
$repositoryConfigJson = json_encode([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
], JSON_THROW_ON_ERROR);
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build:prod',
'start_command' => 'node server.js',
],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => $repositoryConfigJson,
],
);
$repositoryConfig = invokeRailpackMethod(
$job,
$reflection,
'decode_railpack_config',
[$repositoryConfigJson, 'repository railpack.json'],
);
$overrides = [
'deploy' => [
'variables' => [
'APP_ENV' => 'production',
],
],
'packages' => [
'python' => '3.13',
],
];
$generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]);
expect($generatedConfig)->toMatchArray([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
'python' => '3.13',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
'APP_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
]);
});
it('writes a generated railpack config file when repository config exists', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => json_encode([
'$schema' => 'https://schema.railpack.com',
'steps' => [
'build' => [
'commands' => ['npm run build'],
],
],
], JSON_THROW_ON_ERROR),
],
);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBe('.coolify/railpack.generated.json');
expect($job->recordedCommands)->toHaveCount(3);
});
it('does not generate a railpack config file for command overrides alone', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBeNull();
expect($job->recordedCommands)->toHaveCount(1);
});
it('fails fast when repository railpack config is invalid json', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => '{"steps":{"build":',
],
);
expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'))
->toThrow(DeploymentException::class, 'Invalid repository railpack.json');
});
it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
],
);
$envRailpackArgsProperty = $reflection->getProperty('env_railpack_args');
$envRailpackArgsProperty->setAccessible(true);
$envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'");
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_prepare_command',
['.coolify/railpack.generated.json'],
);
expect($command)->toContain('railpack prepare');
expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'");
expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build'));
expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js'));
expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json'));
expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app');
expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD=");
expect($command)->not->toContain("--env 'RAILPACK_START_CMD=");
expect($command)->not->toContain('RAILPACK_BUILD_CMD=');
expect($command)->not->toContain('RAILPACK_START_CMD=');
});
it('fails fast when docker buildx is unavailable for railpack builds', function () {
[$job, $reflection] = makeRailpackDeploymentJob();
$dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable');
$dockerBuildxAvailableProperty->setAccessible(true);
$dockerBuildxAvailableProperty->setValue($job, false);
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack'))
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
});
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'SECRET_JSON' => '{"token":"abc"}',
]),
],
);
expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'");
expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'");
expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'");
expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'");
expect($command)->toContain(' --build-arg secrets-hash=');
expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"');
});
@@ -0,0 +1,267 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Server;
it('generates escaped railpack env args from resolved values and includes install command', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall');
$nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nodeVersion->forceFill([
'key' => 'RAILPACK_NODE_VERSION',
'is_literal' => false,
'is_multiline' => false,
]);
$nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22');
$literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$literalValue->forceFill([
'key' => 'RAILPACK_CUSTOM_FLAG',
'is_literal' => true,
'is_multiline' => false,
]);
$literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'");
$jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$jsonValue->forceFill([
'key' => 'RAILPACK_JSON',
'is_literal' => false,
'is_multiline' => false,
]);
$jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}');
$nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nullValue->forceFill([
'key' => 'RAILPACK_NULL',
'is_literal' => false,
'is_multiline' => false,
]);
$nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null);
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($variables->all())->toBe([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_CUSTOM_FLAG' => 'hello world',
'RAILPACK_JSON' => '{"token":"abc"}',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('RAILPACK_NULL');
});
it('uses preview railpack environment variables for preview deployments', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$previewValue->forceFill([
'key' => 'RAILPACK_PREVIEW_ONLY',
'is_literal' => false,
'is_multiline' => false,
]);
$previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value');
$previewQuery = Mockery::mock();
$previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$previewQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery);
$railpackPreviewQuery = Mockery::mock();
$railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue]));
$application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 42);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
});
it('merges coolify env variables into railpack build variables', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$userVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
$userVar->forceFill([
'key' => 'MY_BUILD_VAR',
'is_literal' => false,
'is_multiline' => false,
]);
$userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')
->with(true)
->andReturn(collect([
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
'EMPTY_VAR' => '',
'NULL_VAR' => null,
]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'MY_BUILD_VAR' => 'hello',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
]);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('EMPTY_VAR');
expect($envArgs)->not->toContain('NULL_VAR');
});
it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial();
$deployPackages->forceFill([
'key' => 'RAILPACK_DEPLOY_APT_PACKAGES',
'is_literal' => false,
'is_multiline' => false,
]);
$deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
});
@@ -11,7 +11,7 @@ use App\Models\ApplicationSetting;
it('casts is_static to boolean when true', function () {
$setting = new ApplicationSetting;
$setting->is_static = true;
$setting->setRawAttributes(['is_static' => true]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeTrue()
@@ -20,7 +20,7 @@ it('casts is_static to boolean when true', function () {
it('casts is_static to boolean when false', function () {
$setting = new ApplicationSetting;
$setting->is_static = false;
$setting->setRawAttributes(['is_static' => false]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeFalse()
@@ -29,7 +29,7 @@ it('casts is_static to boolean when false', function () {
it('casts is_static from string "1" to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = '1';
$setting->setRawAttributes(['is_static' => '1']);
// Should cast string to boolean
expect($setting->is_static)->toBeTrue()
@@ -38,7 +38,7 @@ it('casts is_static from string "1" to boolean true', function () {
it('casts is_static from string "0" to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = '0';
$setting->setRawAttributes(['is_static' => '0']);
// Should cast string to boolean
expect($setting->is_static)->toBeFalse()
@@ -47,7 +47,7 @@ it('casts is_static from string "0" to boolean false', function () {
it('casts is_static from integer 1 to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = 1;
$setting->setRawAttributes(['is_static' => 1]);
// Should cast integer to boolean
expect($setting->is_static)->toBeTrue()
@@ -56,7 +56,7 @@ it('casts is_static from integer 1 to boolean true', function () {
it('casts is_static from integer 0 to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = 0;
$setting->setRawAttributes(['is_static' => 0]);
// Should cast integer to boolean
expect($setting->is_static)->toBeFalse()
@@ -103,3 +103,65 @@ it('casts all boolean fields correctly', function () {
->and($casts[$field])->toBe('boolean');
}
});
it('casts stop_grace_period to integer', function () {
$setting = new ApplicationSetting;
$casts = $setting->getCasts();
expect($casts)->toHaveKey('stop_grace_period')
->and($casts['stop_grace_period'])->toBe('integer');
});
it('handles null stop_grace_period for default behavior', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = null;
expect($setting->stop_grace_period)->toBeNull();
});
it('casts stop_grace_period from string to integer', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = '60';
expect($setting->stop_grace_period)->toBe(60)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period zero to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = 0;
expect($setting->stop_grace_period)->toBe(0)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = -10;
expect($setting->stop_grace_period)->toBe(-10)
->and($setting->stop_grace_period)->toBeInt();
});
it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe($expectedValue);
})->with([
'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS],
'custom' => [300, 300],
'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS],
]);
it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
})->with([
'null' => [null],
'zero' => [0],
'negative' => [-10],
'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1],
]);
@@ -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']);
});
@@ -0,0 +1,123 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function snapshotTestApplication(array $attributes = []): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create(array_merge([
'environment_id' => $environment->id,
'status' => 'running:healthy',
'fqdn' => 'https://example.com',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
], $attributes));
}
function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => (string) $application->id,
'deployment_uuid' => (string) Str::uuid(),
'status' => 'finished',
'commit' => 'HEAD',
]);
$application->markDeploymentConfigurationApplied($deployment);
return $deployment->refresh();
}
it('does not report preview deployment toggles as pending production configuration changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->settings->update(['is_preview_deployments_enabled' => true]);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
});
it('detects build-impacting changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeTrue()
->and(collect($diff->changes())->pluck('label'))->toContain('Build command');
});
it('detects redeploy-only domain changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['fqdn' => 'https://new.example.com']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
});
it('detects environment variable value changes without exposing secret values', function () {
$application = snapshotTestApplication();
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'old-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
markSnapshotTestApplicationDeployed($application->refresh());
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->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');
});
it('describes added environment variables as set without exposing secret values', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'new-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
});
+115
View File
@@ -0,0 +1,115 @@
<?php
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
$harness = new class
{
use DetectsSkipDeployCommits;
};
$harnessClass = get_class($harness);
describe('shouldSkipDeploy (all-must-match)', function () use ($harnessClass) {
test('returns false when messages array is empty', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy([]))->toBeFalse();
});
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse();
});
test('returns true when all messages contain [skip ci]', function () use ($harnessClass) {
$messages = [
'Update docs [skip ci]',
'Fix typo [skip ci]',
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
});
test('returns true when single message contains [skip cd]', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue();
});
test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) {
$messages = [
'Docs [SKIP CI]',
'Changelog [Skip Cd]',
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
});
test('returns false when at least one message has no skip marker', function () use ($harnessClass) {
$messages = [
'Update docs [skip ci]',
'Actual feature change',
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse();
});
test('returns false when single message has no skip marker', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse();
});
test('null entries are filtered before evaluation', function () use ($harnessClass) {
$messages = [
null,
'Docs [skip ci]',
null,
];
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
});
test('matches PR title scenario (single string)', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue();
expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse();
expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse();
});
});
describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) {
test('returns false when messages array is empty', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse();
});
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
});
test('returns true when any one message contains [skip ci]', function () use ($harnessClass) {
$messages = [
'Real feature change',
'docs: update readme [skip ci]',
];
expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue();
});
test('returns true when any one message contains [skip cd]', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue();
});
test('returns true case-insensitively', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue();
expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue();
});
test('returns false when no message contains a skip marker', function () use ($harnessClass) {
$messages = [
'feat: add new endpoint',
'fix: handle edge case',
];
expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse();
});
test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue();
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
});
test('PR title alone with skip marker triggers skip', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue();
});
test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) {
expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue();
});
});
@@ -0,0 +1,23 @@
<?php
it('requires a mail driver before Docmost can start', function () {
$compose = file_get_contents(__DIR__.'/../../templates/compose/docmost.yaml');
expect($compose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
$templates = json_decode(
file_get_contents(__DIR__."/../../templates/{$templateFile}"),
associative: true,
flags: JSON_THROW_ON_ERROR,
);
$generatedCompose = base64_decode($templates['docmost']['compose'], strict: true);
expect($generatedCompose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
}
});
@@ -0,0 +1,74 @@
<?php
use App\Models\EnvironmentVariable;
use App\Models\SharedEnvironmentVariable;
it('flags NIXPACKS_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'NIXPACKS_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('flags RAILPACK_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'RAILPACK_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('does not flag user-defined keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'MY_BUILD_VAR';
expect($env->is_buildpack_control)->toBeFalse();
});
it('does not flag empty key as buildpack control variable', function () {
$env = new EnvironmentVariable;
expect($env->is_buildpack_control)->toBeFalse();
});
it('lists is_buildpack_control in appends and drops legacy is_nixpacks', function () {
$env = new EnvironmentVariable;
expect($env->getAppends())->toContain('is_buildpack_control');
expect($env->getAppends())->not->toContain('is_nixpacks');
});
it('normalizes environment variable keys before storing them on the model', function () {
$env = new EnvironmentVariable;
$env->key = ' node.name ';
expect($env->key)->toBe('node.name');
});
it('allows Docker-compatible environment variable keys on the model', function (string $key) {
$env = new EnvironmentVariable;
$env->key = $key;
expect($env->key)->toBe($key);
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
]);
it('rejects environment variable keys Docker cannot represent on the model', function () {
$env = new EnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});
it('rejects shared environment variable keys Docker cannot represent on the model', function () {
$env = new SharedEnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});
+29
View File
@@ -0,0 +1,29 @@
<?php
test('hex magic variables generate valid hex strings with expected lengths', function (string $command, int $expectedLength) {
$value = generateEnvValue($command);
expect($value)
->toBeString()
->toMatch('/^[0-9a-f]+$/');
expect(strlen($value))->toBe($expectedLength);
})->with([
'HEX_32' => ['HEX_32', 32],
'HEX_64' => ['HEX_64', 64],
'HEX_128' => ['HEX_128', 128],
]);
test('real base64 magic variables generate valid base64 strings from expected byte lengths', function (string $command, int $expectedBytes) {
$value = generateEnvValue($command);
$decodedValue = base64_decode($value, true);
expect($value)->toBeString();
expect($decodedValue)->not->toBeFalse();
expect(strlen($decodedValue))->toBe($expectedBytes);
})->with([
'REALBASE64' => ['REALBASE64', 32],
'REALBASE64_32' => ['REALBASE64_32', 32],
'REALBASE64_64' => ['REALBASE64_64', 64],
'REALBASE64_128' => ['REALBASE64_128', 128],
]);
+61
View File
@@ -0,0 +1,61 @@
<?php
use App\Models\Server;
afterEach(function () {
Mockery::close();
});
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
{
$settings = Mockery::mock();
$settings->is_reachable = $isReachable;
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('refresh')->andReturnSelf();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
return $server;
}
it('sends Reachable notification when reachable and notification was previously sent', function () {
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
$server->shouldReceive('sendReachableNotification')->once();
$server->shouldNotReceive('sendUnreachableNotification');
$server->isReachableChanged();
});
it('does not send any notification when reachable and notification was never sent', function () {
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
$server->shouldNotReceive('sendReachableNotification');
$server->shouldNotReceive('sendUnreachableNotification');
$server->isReachableChanged();
});
it('sends Unreachable notification when count >= 2 and not yet notified', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
$server->shouldReceive('sendUnreachableNotification')->once();
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
it('does not send Unreachable notification on first transient failure (count=1)', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
$server->shouldNotReceive('sendUnreachableNotification');
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
it('does not double-send Unreachable when already notified', function () {
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
$server->shouldNotReceive('sendUnreachableNotification');
$server->shouldNotReceive('sendReachableNotification');
$server->isReachableChanged();
});
@@ -0,0 +1,66 @@
<?php
/**
* Unit tests for LocalFileVolume content size handling.
*
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
* mount large host files. Coolify previously stored full file content in the
* encrypted `content` mediumText column, then serialized it to the Livewire
* payload, crashing the browser.
*/
use App\Models\LocalFileVolume;
use Tests\TestCase;
uses(TestCase::class);
it('exposes a 5 MiB content size limit', function () {
expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
});
it('exposes binary and too-large placeholder constants', function () {
expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
});
it('flags is_too_large when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
expect($volume->is_too_large)->toBeTrue();
expect($volume->is_binary)->toBeFalse();
});
it('flags is_binary when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
expect($volume->is_binary)->toBeTrue();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag normal content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = "hello\nworld\n";
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag empty content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = null;
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('exposes the too-large flag via toArray for Livewire serialization', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
$array = $volume->toArray();
expect($array)->toHaveKey('is_too_large');
expect($array['is_too_large'])->toBeTrue();
});
@@ -2,6 +2,9 @@
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
uses(TestCase::class);
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
+51
View File
@@ -1,11 +1,13 @@
<?php
use App\Events\ServerReachabilityChanged;
use App\Jobs\ServerCheckJob;
use App\Jobs\ServerConnectionCheckJob;
use App\Jobs\ServerManagerJob;
use App\Models\Server;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
uses(TestCase::class);
@@ -126,16 +128,21 @@ describe('shouldSkipDueToBackoff', function () {
describe('ServerConnectionCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
Event::fake([ServerReachabilityChanged::class]);
$settings = Mockery::mock();
$settings->is_reachable = true;
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
$job = new ServerConnectionCheckJob($server);
$job->failed(new TimeoutExceededException);
@@ -152,6 +159,50 @@ describe('ServerConnectionCheckJob unreachable_count', function () {
});
});
describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
// ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
// call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
// through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
$invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
if ($expectDispatch) {
$server->shouldReceive('isReachableChanged')->once()->andReturnNull();
} else {
$server->shouldNotReceive('isReachableChanged');
}
$job = new ServerConnectionCheckJob($server);
$method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
$method->invoke($job, $wasReachable, $wasNotified, $isReachable);
};
it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
$invoke(true, false, false, 2, true);
});
it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
$invoke(true, false, false, 1, false);
});
it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
$invoke(false, true, false, 5, false);
});
it('dispatches recovery event when previously unreachable', function () use ($invoke) {
$invoke(false, false, true, 0, true);
});
it('dispatches recovery event when previously notified', function () use ($invoke) {
$invoke(true, true, true, 0, true);
});
it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
$invoke(true, false, true, 0, false);
});
});
describe('ServerCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
+15 -3
View File
@@ -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
// -------------------------------------------------------------------------
@@ -0,0 +1,45 @@
<?php
use App\Models\StandaloneDocker;
use Illuminate\Database\Eloquent\Model;
/**
* Guards STANDALONE_DATABASE_MODELS against drift.
*
* MCP and API endpoints rely on this registry for team-scoped UUID lookups.
* If a new App\Models\Standalone* model lands without a registry entry, the
* helpers in bootstrap/helpers/shared.php silently fail to resolve it.
*/
test('STANDALONE_DATABASE_MODELS contains every Standalone* model on disk', function () {
$files = glob(dirname(__DIR__, 2).'/app/Models/Standalone*.php');
expect($files)->not->toBeEmpty();
$onDisk = collect($files)
->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php'))
->reject(fn (string $class) => $class === StandaloneDocker::class)
->sort()
->values()
->all();
$registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all();
expect($registered)->toBe(
$onDisk,
'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. '
.'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.'
);
});
test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () {
expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES);
});
test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () {
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}");
expect(is_subclass_of($modelClass, Model::class))
->toBeTrue("{$modelClass} is not an Eloquent model");
expect(method_exists($modelClass, 'team'))
->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()");
}
});
+43
View File
@@ -1,5 +1,6 @@
<?php
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
it('accepts valid names with common characters', function (string $name) {
@@ -130,3 +131,45 @@ it('generates nullable dockerNetworkRules when not required', function () {
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('accepts Docker-compatible environment variable keys', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue();
})->with([
'letters' => 'APP_ENV',
'leading underscore' => '_TOKEN',
'railpack control variable' => 'RAILPACK_NODE_VERSION',
'digits after first character' => 'NODE_VERSION_20',
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
]);
it('rejects environment variable keys Docker cannot represent', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse();
})->with([
'equals' => 'BAD=KEY',
'empty' => '',
]);
it('generates environment variable key rules with correct defaults', function () {
$rules = ValidationPatterns::environmentVariableKeyRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::ENVIRONMENT_VARIABLE_KEY_PATTERN);
});
it('normalizes environment variable keys by trimming surrounding whitespace', function () {
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name');
});
it('normalizes environment variable keys before model validation', function () {
$environmentVariable = new EnvironmentVariable;
$environmentVariable->key = ' APP_ENV ';
expect($environmentVariable->key)->toBe('APP_ENV');
});